gt-sanity 0.0.5 → 0.0.6
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/LICENSE.md +1 -8
- package/README.md +5 -5
- package/dist/index.d.mts +35 -30
- package/dist/index.d.ts +35 -30
- package/dist/index.js +234 -123
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +237 -124
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -3
- package/src/adapter/core.ts +72 -7
- package/src/adapter/createTask.ts +1 -1
- package/src/adapter/types.ts +9 -0
- package/src/components/LanguageStatus.tsx +2 -0
- package/src/components/NewTask.tsx +2 -0
- package/src/components/ProgressBar.tsx +2 -0
- package/src/components/TaskView.tsx +2 -0
- package/src/components/TranslationContext.tsx +5 -0
- package/src/components/TranslationView.tsx +34 -2
- package/src/components/TranslationsTab.tsx +4 -0
- package/src/components/page/TranslationsTool.tsx +876 -0
- package/src/configuration/baseDocumentLevelConfig/documentLevelPatch.ts +23 -10
- package/src/configuration/baseDocumentLevelConfig/helpers/createI18nDocAndPatchMetadata.ts +77 -24
- package/src/configuration/baseDocumentLevelConfig/helpers/createTranslationMetadata.ts +2 -0
- package/src/configuration/baseDocumentLevelConfig/helpers/getOrCreateTranslationMetadata.ts +2 -0
- package/src/configuration/baseDocumentLevelConfig/helpers/getTranslationMetadata.ts +2 -0
- package/src/configuration/baseDocumentLevelConfig/helpers/patchI18nDoc.ts +51 -8
- package/src/configuration/baseDocumentLevelConfig/index.ts +6 -37
- package/src/configuration/baseFieldLevelConfig.ts +4 -1
- package/src/configuration/utils/checkSerializationVersion.ts +2 -0
- package/src/configuration/utils/findDocumentAtRevision.ts +2 -0
- package/src/configuration/utils/findLatestDraft.ts +2 -0
- package/src/hooks/useClient.ts +3 -1
- package/src/hooks/useSecrets.ts +2 -0
- package/src/index.ts +70 -32
- package/src/translation/checkTranslationStatus.ts +42 -0
- package/src/translation/createJobs.ts +16 -0
- package/src/translation/downloadTranslations.ts +68 -0
- package/src/translation/importDocument.ts +24 -0
- package/src/translation/initProject.ts +61 -0
- package/src/translation/uploadFiles.ts +32 -0
- package/src/types.ts +4 -1
- package/src/utils/applyDocuments.ts +72 -0
- package/src/utils/serialize.ts +32 -0
- package/src/utils/shared.ts +1 -0
- package/src/configuration/baseDocumentLevelConfig/helpers/index.ts +0 -5
- package/src/configuration/baseDocumentLevelConfig/legacyDocumentLevelPatch.ts +0 -69
- package/src/configuration/index.ts +0 -18
- package/src/configuration/utils/index.ts +0 -3
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Button,
|
|
5
|
+
Card,
|
|
6
|
+
Container,
|
|
7
|
+
Dialog,
|
|
8
|
+
Flex,
|
|
9
|
+
Heading,
|
|
10
|
+
Spinner,
|
|
11
|
+
Stack,
|
|
12
|
+
Switch,
|
|
13
|
+
Text,
|
|
14
|
+
ThemeProvider,
|
|
15
|
+
ToastProvider,
|
|
16
|
+
useToast,
|
|
17
|
+
} from '@sanity/ui';
|
|
18
|
+
import { DownloadIcon, CheckmarkCircleIcon } from '@sanity/icons';
|
|
19
|
+
import { buildTheme } from '@sanity/ui/theme';
|
|
20
|
+
import { Link } from 'sanity/router';
|
|
21
|
+
import { SanityDocument, useSchema } from 'sanity';
|
|
22
|
+
import { useClient } from '../../hooks/useClient';
|
|
23
|
+
import { useSecrets } from '../../hooks/useSecrets';
|
|
24
|
+
import { GTAdapter } from '../../adapter';
|
|
25
|
+
import {
|
|
26
|
+
GTFile,
|
|
27
|
+
Secrets,
|
|
28
|
+
TranslationLocale,
|
|
29
|
+
TranslationFunctionContext,
|
|
30
|
+
} from '../../types';
|
|
31
|
+
import { gtConfig } from '../../adapter/core';
|
|
32
|
+
import { LanguageStatus } from '../LanguageStatus';
|
|
33
|
+
import { serializeDocument } from '../../utils/serialize';
|
|
34
|
+
import { uploadFiles } from '../../translation/uploadFiles';
|
|
35
|
+
import { initProject } from '../../translation/initProject';
|
|
36
|
+
import { createJobs } from '../../translation/createJobs';
|
|
37
|
+
import {
|
|
38
|
+
downloadTranslations,
|
|
39
|
+
BatchedFiles,
|
|
40
|
+
} from '../../translation/downloadTranslations';
|
|
41
|
+
import { checkTranslationStatus } from '../../translation/checkTranslationStatus';
|
|
42
|
+
import { importDocument } from '../../translation/importDocument';
|
|
43
|
+
|
|
44
|
+
const theme = buildTheme();
|
|
45
|
+
|
|
46
|
+
const TranslationsTool = () => {
|
|
47
|
+
const [isTranslateAllDialogOpen, setIsTranslateAllDialogOpen] =
|
|
48
|
+
useState(false);
|
|
49
|
+
const [isImportAllDialogOpen, setIsImportAllDialogOpen] = useState(false);
|
|
50
|
+
const [isBusy, setIsBusy] = useState(false);
|
|
51
|
+
const [documents, setDocuments] = useState<SanityDocument[]>([]);
|
|
52
|
+
const [locales, setLocales] = useState<TranslationLocale[]>([]);
|
|
53
|
+
const [autoPublish, setAutoPublish] = useState(true);
|
|
54
|
+
const [autoRefresh, setAutoRefresh] = useState(false);
|
|
55
|
+
const [loadingDocuments, setLoadingDocuments] = useState(false);
|
|
56
|
+
const [importProgress, setImportProgress] = useState({
|
|
57
|
+
current: 0,
|
|
58
|
+
total: 0,
|
|
59
|
+
isImporting: false,
|
|
60
|
+
});
|
|
61
|
+
const [importedTranslations, setImportedTranslations] = useState<Set<string>>(
|
|
62
|
+
new Set()
|
|
63
|
+
);
|
|
64
|
+
const [downloadStatus, setDownloadStatus] = useState({
|
|
65
|
+
downloaded: new Set<string>(),
|
|
66
|
+
failed: new Set<string>(),
|
|
67
|
+
skipped: new Set<string>(),
|
|
68
|
+
});
|
|
69
|
+
const [translationStatuses, setTranslationStatuses] = useState<
|
|
70
|
+
Map<string, { progress: number; isReady: boolean; translationId?: string }>
|
|
71
|
+
>(new Map());
|
|
72
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
73
|
+
|
|
74
|
+
const client = useClient();
|
|
75
|
+
const schema = useSchema();
|
|
76
|
+
const translationContext: TranslationFunctionContext = { client, schema };
|
|
77
|
+
const toast = useToast();
|
|
78
|
+
const { loading: loadingSecrets, secrets } = useSecrets<Secrets>(
|
|
79
|
+
`${gtConfig.getSecretsNamespace()}.secrets`
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const fetchDocuments = useCallback(async () => {
|
|
83
|
+
setLoadingDocuments(true);
|
|
84
|
+
try {
|
|
85
|
+
const translateDocuments = gtConfig.getTranslateDocuments();
|
|
86
|
+
|
|
87
|
+
// Build filter conditions based on translateDocuments configuration
|
|
88
|
+
const filterConditions = translateDocuments
|
|
89
|
+
.map((filter) => {
|
|
90
|
+
if (filter.type && filter.documentId) {
|
|
91
|
+
// Both type and documentId must match
|
|
92
|
+
return `(_type == "${filter.type}" && _id == "${filter.documentId}")`;
|
|
93
|
+
} else if (filter.type) {
|
|
94
|
+
// Only type must match
|
|
95
|
+
return `_type == "${filter.type}"`;
|
|
96
|
+
} else if (filter.documentId) {
|
|
97
|
+
// Only documentId must match
|
|
98
|
+
return `_id == "${filter.documentId}"`;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
})
|
|
102
|
+
.filter(Boolean);
|
|
103
|
+
|
|
104
|
+
// If no filters are configured, fall back to the original query
|
|
105
|
+
const languageField = gtConfig.getLanguageField();
|
|
106
|
+
const sourceLocale = gtConfig.getSourceLocale();
|
|
107
|
+
const languageFilter = `(!defined(${languageField}) || ${languageField} == "${sourceLocale}")`;
|
|
108
|
+
|
|
109
|
+
let query;
|
|
110
|
+
if (filterConditions.length === 0) {
|
|
111
|
+
query = `*[!(_type in ["system.group"]) && !(_id in path("_.**")) && ${languageFilter}]`;
|
|
112
|
+
} else {
|
|
113
|
+
const filterQuery = filterConditions.join(' || ');
|
|
114
|
+
query = `*[!(_type in ["system.group"]) && !(_id in path("_.**")) && (${filterQuery}) && ${languageFilter}]`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const docs = await client.fetch(query);
|
|
118
|
+
setDocuments(docs);
|
|
119
|
+
} catch {
|
|
120
|
+
toast.push({
|
|
121
|
+
title: 'Error fetching documents',
|
|
122
|
+
status: 'error',
|
|
123
|
+
closable: true,
|
|
124
|
+
});
|
|
125
|
+
} finally {
|
|
126
|
+
setLoadingDocuments(false);
|
|
127
|
+
}
|
|
128
|
+
}, [client, toast]);
|
|
129
|
+
|
|
130
|
+
const fetchLocales = useCallback(async () => {
|
|
131
|
+
if (!secrets) return;
|
|
132
|
+
try {
|
|
133
|
+
const availableLocales = await GTAdapter.getLocales(secrets);
|
|
134
|
+
setLocales(availableLocales);
|
|
135
|
+
} catch {
|
|
136
|
+
toast.push({
|
|
137
|
+
title: 'Error fetching locales',
|
|
138
|
+
status: 'error',
|
|
139
|
+
closable: true,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}, [secrets, toast]);
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
fetchDocuments();
|
|
146
|
+
}, [fetchDocuments]);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (secrets) {
|
|
150
|
+
fetchLocales();
|
|
151
|
+
}
|
|
152
|
+
}, [fetchLocales, secrets]);
|
|
153
|
+
|
|
154
|
+
const handleTranslateAll = useCallback(async () => {
|
|
155
|
+
if (!secrets || documents.length === 0) return;
|
|
156
|
+
|
|
157
|
+
setIsBusy(true);
|
|
158
|
+
setIsTranslateAllDialogOpen(false);
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const availableLocaleIds = locales
|
|
162
|
+
.filter((locale) => locale.enabled !== false)
|
|
163
|
+
.map((locale) => locale.localeId);
|
|
164
|
+
|
|
165
|
+
console.log('documents', documents);
|
|
166
|
+
// Transform documents to the required format
|
|
167
|
+
const transformedDocuments = documents
|
|
168
|
+
.map((doc) => {
|
|
169
|
+
delete doc[gtConfig.getLanguageField()];
|
|
170
|
+
const baseLanguage = gtConfig.getSourceLocale();
|
|
171
|
+
try {
|
|
172
|
+
const serialized = serializeDocument(doc, schema, baseLanguage);
|
|
173
|
+
return {
|
|
174
|
+
info: {
|
|
175
|
+
documentId: doc._id?.replace('drafts.', '') || doc._id,
|
|
176
|
+
versionId: doc._rev,
|
|
177
|
+
},
|
|
178
|
+
serializedDocument: serialized,
|
|
179
|
+
};
|
|
180
|
+
} catch (error) {
|
|
181
|
+
console.error('Error transforming document', doc._id, error);
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
})
|
|
185
|
+
.filter((doc) => doc !== null);
|
|
186
|
+
|
|
187
|
+
console.log('transformedDocuments', transformedDocuments);
|
|
188
|
+
console.log('transformedDocuments.length', transformedDocuments.length);
|
|
189
|
+
const uploadResult = await uploadFiles(transformedDocuments, secrets);
|
|
190
|
+
await initProject(uploadResult, { timeout: 600 }, secrets);
|
|
191
|
+
await createJobs(uploadResult, availableLocaleIds, secrets);
|
|
192
|
+
|
|
193
|
+
toast.push({
|
|
194
|
+
title: `Translation tasks created for ${documents.length} documents`,
|
|
195
|
+
status: 'success',
|
|
196
|
+
closable: true,
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
toast.push({
|
|
200
|
+
title: 'Error creating translation tasks',
|
|
201
|
+
status: 'error',
|
|
202
|
+
closable: true,
|
|
203
|
+
});
|
|
204
|
+
} finally {
|
|
205
|
+
setIsBusy(false);
|
|
206
|
+
}
|
|
207
|
+
}, [secrets, documents, locales, toast]);
|
|
208
|
+
|
|
209
|
+
const handleImportAll = useCallback(async () => {
|
|
210
|
+
if (!secrets || documents.length === 0) return;
|
|
211
|
+
|
|
212
|
+
setIsBusy(true);
|
|
213
|
+
setIsImportAllDialogOpen(false);
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
// Collect all ready translations
|
|
217
|
+
const readyFiles: BatchedFiles = [];
|
|
218
|
+
|
|
219
|
+
for (const [key, status] of translationStatuses.entries()) {
|
|
220
|
+
if (status.isReady && status.translationId) {
|
|
221
|
+
const [documentId, locale] = key.split(':');
|
|
222
|
+
const document = documents.find(
|
|
223
|
+
(doc) => (doc._id?.replace('drafts.', '') || doc._id) === documentId
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (document) {
|
|
227
|
+
readyFiles.push({
|
|
228
|
+
documentId,
|
|
229
|
+
versionId: document._rev,
|
|
230
|
+
translationId: status.translationId,
|
|
231
|
+
locale,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (readyFiles.length === 0) {
|
|
238
|
+
toast.push({
|
|
239
|
+
title: 'No ready translations to import',
|
|
240
|
+
status: 'warning',
|
|
241
|
+
closable: true,
|
|
242
|
+
});
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Download all ready translations
|
|
247
|
+
const downloadedFiles = await downloadTranslations(readyFiles, secrets);
|
|
248
|
+
|
|
249
|
+
// Set up progress tracking
|
|
250
|
+
setImportProgress({
|
|
251
|
+
current: 0,
|
|
252
|
+
total: downloadedFiles.length,
|
|
253
|
+
isImporting: true,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Batch import in groups of 10
|
|
257
|
+
const batchSize = 10;
|
|
258
|
+
let successCount = 0;
|
|
259
|
+
let failureCount = 0;
|
|
260
|
+
const successfulImports: string[] = [];
|
|
261
|
+
|
|
262
|
+
for (let i = 0; i < downloadedFiles.length; i += batchSize) {
|
|
263
|
+
const batch = downloadedFiles.slice(i, i + batchSize);
|
|
264
|
+
|
|
265
|
+
// Process batch in parallel
|
|
266
|
+
const batchPromises = batch.map(async (file) => {
|
|
267
|
+
try {
|
|
268
|
+
const docInfo: GTFile = {
|
|
269
|
+
documentId: file.docData.documentId,
|
|
270
|
+
versionId: file.docData.versionId,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
await importDocument(
|
|
274
|
+
docInfo,
|
|
275
|
+
file.docData.locale,
|
|
276
|
+
file.data,
|
|
277
|
+
translationContext,
|
|
278
|
+
autoPublish
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const key = `${file.docData.documentId}:${file.docData.locale}`;
|
|
282
|
+
successfulImports.push(key);
|
|
283
|
+
setImportedTranslations((prev) => new Set([...prev, key]));
|
|
284
|
+
return { success: true, file };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
console.error(
|
|
287
|
+
`Failed to import ${file.docData.documentId} (${file.docData.locale}):`,
|
|
288
|
+
error
|
|
289
|
+
);
|
|
290
|
+
return { success: false, file, error };
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const batchResults = await Promise.all(batchPromises);
|
|
295
|
+
|
|
296
|
+
batchResults.forEach((result) => {
|
|
297
|
+
if (result.success) {
|
|
298
|
+
successCount++;
|
|
299
|
+
} else {
|
|
300
|
+
failureCount++;
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Update progress
|
|
305
|
+
setImportProgress({
|
|
306
|
+
current: i + batch.length,
|
|
307
|
+
total: downloadedFiles.length,
|
|
308
|
+
isImporting: true,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Update download status for successful imports
|
|
313
|
+
if (successfulImports.length > 0) {
|
|
314
|
+
const newDownloadStatus = {
|
|
315
|
+
...downloadStatus,
|
|
316
|
+
downloaded: new Set([
|
|
317
|
+
...downloadStatus.downloaded,
|
|
318
|
+
...successfulImports,
|
|
319
|
+
]),
|
|
320
|
+
};
|
|
321
|
+
setDownloadStatus(newDownloadStatus);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
toast.push({
|
|
325
|
+
title: `Imported ${successCount} translations${failureCount > 0 ? `, ${failureCount} failed` : ''}`,
|
|
326
|
+
status: successCount > 0 ? 'success' : 'error',
|
|
327
|
+
closable: true,
|
|
328
|
+
});
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error('Error importing translations:', error);
|
|
331
|
+
toast.push({
|
|
332
|
+
title: 'Error importing translations',
|
|
333
|
+
status: 'error',
|
|
334
|
+
closable: true,
|
|
335
|
+
});
|
|
336
|
+
} finally {
|
|
337
|
+
setIsBusy(false);
|
|
338
|
+
setImportProgress({ current: 0, total: 0, isImporting: false });
|
|
339
|
+
}
|
|
340
|
+
}, [
|
|
341
|
+
secrets,
|
|
342
|
+
documents,
|
|
343
|
+
translationStatuses,
|
|
344
|
+
downloadStatus,
|
|
345
|
+
toast,
|
|
346
|
+
autoPublish,
|
|
347
|
+
translationContext,
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
const handleRefreshAll = useCallback(async () => {
|
|
351
|
+
if (!secrets || documents.length === 0) return;
|
|
352
|
+
|
|
353
|
+
setIsRefreshing(true);
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const availableLocaleIds = locales
|
|
357
|
+
.filter((locale) => locale.enabled !== false)
|
|
358
|
+
.map((locale) => locale.localeId);
|
|
359
|
+
|
|
360
|
+
// Create file query data for all document/locale combinations
|
|
361
|
+
const fileQueryData = [];
|
|
362
|
+
for (const doc of documents) {
|
|
363
|
+
for (const localeId of availableLocaleIds) {
|
|
364
|
+
const documentId = doc._id?.replace('drafts.', '') || doc._id;
|
|
365
|
+
fileQueryData.push({
|
|
366
|
+
versionId: doc._rev,
|
|
367
|
+
fileId: documentId,
|
|
368
|
+
locale: localeId,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// Check translation status for all files
|
|
373
|
+
const readyTranslations = await checkTranslationStatus(
|
|
374
|
+
fileQueryData,
|
|
375
|
+
downloadStatus,
|
|
376
|
+
secrets
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// Update translation statuses
|
|
380
|
+
const newStatuses = new Map(translationStatuses);
|
|
381
|
+
|
|
382
|
+
// Reset all to not ready first
|
|
383
|
+
for (const doc of documents) {
|
|
384
|
+
for (const localeId of availableLocaleIds) {
|
|
385
|
+
const documentId = doc._id?.replace('drafts.', '') || doc._id;
|
|
386
|
+
const key = `${documentId}:${localeId}`;
|
|
387
|
+
newStatuses.set(key, { progress: 0, isReady: false });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Update with ready translations
|
|
392
|
+
if (Array.isArray(readyTranslations)) {
|
|
393
|
+
for (const translation of readyTranslations) {
|
|
394
|
+
const key = `${translation.fileId}:${translation.locale}`;
|
|
395
|
+
newStatuses.set(key, {
|
|
396
|
+
progress: 100,
|
|
397
|
+
isReady: true,
|
|
398
|
+
translationId: translation.id,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
setTranslationStatuses(newStatuses);
|
|
404
|
+
|
|
405
|
+
toast.push({
|
|
406
|
+
title: `Refreshed status for ${documents.length} documents`,
|
|
407
|
+
status: 'success',
|
|
408
|
+
closable: true,
|
|
409
|
+
});
|
|
410
|
+
} catch (error) {
|
|
411
|
+
console.error('Error refreshing translation status:', error);
|
|
412
|
+
toast.push({
|
|
413
|
+
title: 'Error refreshing translation status',
|
|
414
|
+
status: 'error',
|
|
415
|
+
closable: true,
|
|
416
|
+
});
|
|
417
|
+
} finally {
|
|
418
|
+
setIsRefreshing(false);
|
|
419
|
+
}
|
|
420
|
+
}, [secrets, documents, locales, downloadStatus, translationStatuses, toast]);
|
|
421
|
+
|
|
422
|
+
// Auto-refresh on page load after documents and locales are loaded
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
if (
|
|
425
|
+
documents.length > 0 &&
|
|
426
|
+
locales.length > 0 &&
|
|
427
|
+
secrets &&
|
|
428
|
+
!loadingDocuments
|
|
429
|
+
) {
|
|
430
|
+
handleRefreshAll();
|
|
431
|
+
}
|
|
432
|
+
}, [documents]);
|
|
433
|
+
|
|
434
|
+
// Auto-refresh functionality
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
if (!autoRefresh || documents.length === 0 || !secrets) return;
|
|
437
|
+
|
|
438
|
+
const interval = setInterval(async () => {
|
|
439
|
+
await handleRefreshAll();
|
|
440
|
+
}, 10000); // Refresh every 10 seconds
|
|
441
|
+
|
|
442
|
+
return () => clearInterval(interval);
|
|
443
|
+
}, [autoRefresh, documents.length, secrets, handleRefreshAll]);
|
|
444
|
+
|
|
445
|
+
// Initialize imported translations from download status
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
setImportedTranslations(new Set(downloadStatus.downloaded));
|
|
448
|
+
}, [downloadStatus.downloaded]);
|
|
449
|
+
|
|
450
|
+
const handleImportDocument = useCallback(
|
|
451
|
+
async (documentId: string, localeId: string) => {
|
|
452
|
+
if (!secrets) return;
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
const key = `${documentId}:${localeId}`;
|
|
456
|
+
const status = translationStatuses.get(key);
|
|
457
|
+
|
|
458
|
+
if (!status?.isReady || !status.translationId) {
|
|
459
|
+
toast.push({
|
|
460
|
+
title: `Translation not ready for ${documentId} (${localeId})`,
|
|
461
|
+
status: 'warning',
|
|
462
|
+
closable: true,
|
|
463
|
+
});
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const document = documents.find(
|
|
468
|
+
(doc) => (doc._id?.replace('drafts.', '') || doc._id) === documentId
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
if (!document) {
|
|
472
|
+
toast.push({
|
|
473
|
+
title: `Document ${documentId} not found`,
|
|
474
|
+
status: 'error',
|
|
475
|
+
closable: true,
|
|
476
|
+
});
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Download single translation
|
|
481
|
+
const downloadedFiles = await downloadTranslations(
|
|
482
|
+
[
|
|
483
|
+
{
|
|
484
|
+
documentId,
|
|
485
|
+
versionId: document._rev,
|
|
486
|
+
translationId: status.translationId,
|
|
487
|
+
locale: localeId,
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
secrets
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
if (downloadedFiles.length > 0) {
|
|
494
|
+
try {
|
|
495
|
+
const docInfo: GTFile = {
|
|
496
|
+
documentId,
|
|
497
|
+
versionId: document._rev,
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
await importDocument(
|
|
501
|
+
docInfo,
|
|
502
|
+
localeId,
|
|
503
|
+
downloadedFiles[0].data,
|
|
504
|
+
translationContext,
|
|
505
|
+
autoPublish
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
// Update download status and imported translations
|
|
509
|
+
const newDownloadStatus = {
|
|
510
|
+
...downloadStatus,
|
|
511
|
+
downloaded: new Set([...downloadStatus.downloaded, key]),
|
|
512
|
+
};
|
|
513
|
+
setDownloadStatus(newDownloadStatus);
|
|
514
|
+
setImportedTranslations((prev) => new Set([...prev, key]));
|
|
515
|
+
|
|
516
|
+
toast.push({
|
|
517
|
+
title: `Successfully imported translation for ${documentId} (${localeId})`,
|
|
518
|
+
status: 'success',
|
|
519
|
+
closable: true,
|
|
520
|
+
});
|
|
521
|
+
} catch (importError) {
|
|
522
|
+
console.error('Failed to import translation:', importError);
|
|
523
|
+
toast.push({
|
|
524
|
+
title: `Failed to import translation for ${documentId} (${localeId})`,
|
|
525
|
+
status: 'error',
|
|
526
|
+
closable: true,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
toast.push({
|
|
531
|
+
title: `No translation content received for ${documentId}`,
|
|
532
|
+
status: 'warning',
|
|
533
|
+
closable: true,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
} catch (error) {
|
|
537
|
+
console.error('Error importing translation:', error);
|
|
538
|
+
toast.push({
|
|
539
|
+
title: `Error importing translation for ${documentId}`,
|
|
540
|
+
status: 'error',
|
|
541
|
+
closable: true,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
[
|
|
546
|
+
secrets,
|
|
547
|
+
translationStatuses,
|
|
548
|
+
documents,
|
|
549
|
+
downloadStatus,
|
|
550
|
+
toast,
|
|
551
|
+
autoPublish,
|
|
552
|
+
translationContext,
|
|
553
|
+
]
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
if (loadingSecrets) {
|
|
557
|
+
return (
|
|
558
|
+
<ThemeProvider theme={theme}>
|
|
559
|
+
<Container width={2}>
|
|
560
|
+
<Flex padding={5} align='center' justify='center'>
|
|
561
|
+
<Spinner />
|
|
562
|
+
</Flex>
|
|
563
|
+
</Container>
|
|
564
|
+
</ThemeProvider>
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (!secrets) {
|
|
569
|
+
return (
|
|
570
|
+
<ThemeProvider theme={theme}>
|
|
571
|
+
<Container width={2}>
|
|
572
|
+
<Box padding={4} marginTop={5}>
|
|
573
|
+
<Card tone='caution' padding={[2, 3, 4, 4]} shadow={1} radius={2}>
|
|
574
|
+
<Text>
|
|
575
|
+
Can't find secrets for your translation service. Did you load
|
|
576
|
+
them into this dataset?
|
|
577
|
+
</Text>
|
|
578
|
+
</Card>
|
|
579
|
+
</Box>
|
|
580
|
+
</Container>
|
|
581
|
+
</ThemeProvider>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return (
|
|
586
|
+
<ThemeProvider theme={theme}>
|
|
587
|
+
<ToastProvider paddingY={7}>
|
|
588
|
+
<Container width={2}>
|
|
589
|
+
<Box padding={4} marginTop={5}>
|
|
590
|
+
<Stack space={4}>
|
|
591
|
+
<Flex align='center' justify='space-between'>
|
|
592
|
+
<Stack space={2}>
|
|
593
|
+
<Heading as='h2' size={3}>
|
|
594
|
+
Translations
|
|
595
|
+
</Heading>
|
|
596
|
+
<Text size={2}>
|
|
597
|
+
Manage your document translations from this centralized
|
|
598
|
+
location.
|
|
599
|
+
</Text>
|
|
600
|
+
</Stack>
|
|
601
|
+
|
|
602
|
+
<Flex gap={3} align='center'>
|
|
603
|
+
<Flex gap={2} align='center'>
|
|
604
|
+
<Text size={1}>Auto-refresh</Text>
|
|
605
|
+
<Switch
|
|
606
|
+
checked={autoRefresh}
|
|
607
|
+
onChange={() => setAutoRefresh(!autoRefresh)}
|
|
608
|
+
/>
|
|
609
|
+
</Flex>
|
|
610
|
+
<Button
|
|
611
|
+
fontSize={1}
|
|
612
|
+
padding={2}
|
|
613
|
+
text={isRefreshing ? 'Refreshing...' : 'Refresh Status'}
|
|
614
|
+
onClick={handleRefreshAll}
|
|
615
|
+
disabled={
|
|
616
|
+
isRefreshing ||
|
|
617
|
+
isBusy ||
|
|
618
|
+
loadingDocuments ||
|
|
619
|
+
documents.length === 0
|
|
620
|
+
}
|
|
621
|
+
/>
|
|
622
|
+
</Flex>
|
|
623
|
+
</Flex>
|
|
624
|
+
|
|
625
|
+
<Stack space={4}>
|
|
626
|
+
<Box>
|
|
627
|
+
<Text size={1} muted>
|
|
628
|
+
{loadingDocuments
|
|
629
|
+
? 'Loading documents...'
|
|
630
|
+
: `Found ${documents.length} documents available for translation`}
|
|
631
|
+
</Text>
|
|
632
|
+
</Box>
|
|
633
|
+
|
|
634
|
+
<Flex justify='center'>
|
|
635
|
+
<Button
|
|
636
|
+
style={{ width: '200px' }}
|
|
637
|
+
tone='critical'
|
|
638
|
+
text={isBusy ? 'Processing...' : 'Translate All'}
|
|
639
|
+
onClick={() => setIsTranslateAllDialogOpen(true)}
|
|
640
|
+
disabled={
|
|
641
|
+
isBusy || loadingDocuments || documents.length === 0
|
|
642
|
+
}
|
|
643
|
+
/>
|
|
644
|
+
</Flex>
|
|
645
|
+
|
|
646
|
+
{loadingDocuments ? (
|
|
647
|
+
<Flex align='center' justify='center' padding={4}>
|
|
648
|
+
<Spinner />
|
|
649
|
+
</Flex>
|
|
650
|
+
) : (
|
|
651
|
+
<Box style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
|
652
|
+
<Stack space={2}>
|
|
653
|
+
{documents.map((document) => (
|
|
654
|
+
<Card key={document._id} shadow={1} padding={3}>
|
|
655
|
+
<Stack space={3}>
|
|
656
|
+
<Flex justify='space-between' align='flex-start'>
|
|
657
|
+
<Box flex={1}>
|
|
658
|
+
<Text weight='semibold' size={1}>
|
|
659
|
+
{document._id?.replace('drafts.', '') ||
|
|
660
|
+
document._id}
|
|
661
|
+
</Text>
|
|
662
|
+
<Text
|
|
663
|
+
size={0}
|
|
664
|
+
muted
|
|
665
|
+
style={{ marginTop: '2px' }}
|
|
666
|
+
>
|
|
667
|
+
{document._type}
|
|
668
|
+
</Text>
|
|
669
|
+
</Box>
|
|
670
|
+
</Flex>
|
|
671
|
+
|
|
672
|
+
<Stack space={2}>
|
|
673
|
+
{locales.length > 0 ? (
|
|
674
|
+
locales
|
|
675
|
+
.filter((locale) => locale.enabled !== false)
|
|
676
|
+
.map((locale) => {
|
|
677
|
+
const documentId =
|
|
678
|
+
document._id?.replace('drafts.', '') ||
|
|
679
|
+
document._id;
|
|
680
|
+
const key = `${documentId}:${locale.localeId}`;
|
|
681
|
+
const status = translationStatuses.get(key);
|
|
682
|
+
const isDownloaded =
|
|
683
|
+
downloadStatus.downloaded.has(key);
|
|
684
|
+
const isImported =
|
|
685
|
+
importedTranslations.has(key);
|
|
686
|
+
|
|
687
|
+
return (
|
|
688
|
+
<LanguageStatus
|
|
689
|
+
key={`${document._id}-${locale.localeId}`}
|
|
690
|
+
title={
|
|
691
|
+
locale.description || locale.localeId
|
|
692
|
+
}
|
|
693
|
+
progress={status?.progress || 0}
|
|
694
|
+
isImported={isImported || isDownloaded}
|
|
695
|
+
importFile={async () => {
|
|
696
|
+
await handleImportDocument(
|
|
697
|
+
documentId,
|
|
698
|
+
locale.localeId
|
|
699
|
+
);
|
|
700
|
+
}}
|
|
701
|
+
/>
|
|
702
|
+
);
|
|
703
|
+
})
|
|
704
|
+
) : (
|
|
705
|
+
<Text size={1} muted>
|
|
706
|
+
No locales configured
|
|
707
|
+
</Text>
|
|
708
|
+
)}
|
|
709
|
+
</Stack>
|
|
710
|
+
</Stack>
|
|
711
|
+
</Card>
|
|
712
|
+
))}
|
|
713
|
+
</Stack>
|
|
714
|
+
</Box>
|
|
715
|
+
)}
|
|
716
|
+
|
|
717
|
+
<Stack space={3}>
|
|
718
|
+
<Flex gap={3} align='center' justify='space-between'>
|
|
719
|
+
<Flex gap={2} align='center'>
|
|
720
|
+
<Button
|
|
721
|
+
mode='ghost'
|
|
722
|
+
onClick={() => setIsImportAllDialogOpen(true)}
|
|
723
|
+
text={isBusy ? 'Importing...' : 'Import All'}
|
|
724
|
+
icon={isBusy ? null : DownloadIcon}
|
|
725
|
+
disabled={
|
|
726
|
+
isBusy || loadingDocuments || documents.length === 0
|
|
727
|
+
}
|
|
728
|
+
/>
|
|
729
|
+
{importedTranslations.size ===
|
|
730
|
+
documents.length *
|
|
731
|
+
locales.filter((l) => l.enabled !== false).length &&
|
|
732
|
+
documents.length > 0 &&
|
|
733
|
+
locales.length > 0 && (
|
|
734
|
+
<Flex
|
|
735
|
+
gap={2}
|
|
736
|
+
align='center'
|
|
737
|
+
style={{ color: 'green' }}
|
|
738
|
+
>
|
|
739
|
+
<CheckmarkCircleIcon />
|
|
740
|
+
<Text size={1}>All translations imported</Text>
|
|
741
|
+
</Flex>
|
|
742
|
+
)}
|
|
743
|
+
{importedTranslations.size > 0 &&
|
|
744
|
+
importedTranslations.size <
|
|
745
|
+
documents.length *
|
|
746
|
+
locales.filter((l) => l.enabled !== false)
|
|
747
|
+
.length && (
|
|
748
|
+
<Text size={1} style={{ color: '#666' }}>
|
|
749
|
+
{importedTranslations.size}/
|
|
750
|
+
{documents.length *
|
|
751
|
+
locales.filter((l) => l.enabled !== false)
|
|
752
|
+
.length}{' '}
|
|
753
|
+
imported
|
|
754
|
+
</Text>
|
|
755
|
+
)}
|
|
756
|
+
</Flex>
|
|
757
|
+
<Flex
|
|
758
|
+
gap={2}
|
|
759
|
+
align='center'
|
|
760
|
+
style={{ whiteSpace: 'nowrap' }}
|
|
761
|
+
>
|
|
762
|
+
<Text size={1}>Auto-Publish</Text>
|
|
763
|
+
<Switch
|
|
764
|
+
checked={autoPublish}
|
|
765
|
+
onChange={() => setAutoPublish(!autoPublish)}
|
|
766
|
+
disabled={isBusy}
|
|
767
|
+
/>
|
|
768
|
+
</Flex>
|
|
769
|
+
</Flex>
|
|
770
|
+
|
|
771
|
+
{/* Import Progress UI */}
|
|
772
|
+
{importProgress.isImporting && (
|
|
773
|
+
<Flex justify='center' align='center' gap={3}>
|
|
774
|
+
<Spinner size={1} />
|
|
775
|
+
<Text size={1}>
|
|
776
|
+
Importing {importProgress.current} of{' '}
|
|
777
|
+
{importProgress.total} translations...
|
|
778
|
+
</Text>
|
|
779
|
+
</Flex>
|
|
780
|
+
)}
|
|
781
|
+
</Stack>
|
|
782
|
+
</Stack>
|
|
783
|
+
|
|
784
|
+
<Text size={2}>
|
|
785
|
+
For more information, see the{' '}
|
|
786
|
+
<Link href='https://dash.generaltranslation.com'>
|
|
787
|
+
General Translation Dashboard
|
|
788
|
+
</Link>
|
|
789
|
+
.
|
|
790
|
+
</Text>
|
|
791
|
+
</Stack>
|
|
792
|
+
</Box>
|
|
793
|
+
|
|
794
|
+
{/* Translate All Confirmation Dialog */}
|
|
795
|
+
{isTranslateAllDialogOpen && (
|
|
796
|
+
<Dialog
|
|
797
|
+
header='Confirm Translation'
|
|
798
|
+
id='translate-all-dialog'
|
|
799
|
+
onClose={() => setIsTranslateAllDialogOpen(false)}
|
|
800
|
+
footer={
|
|
801
|
+
<Box padding={3}>
|
|
802
|
+
<Flex gap={2}>
|
|
803
|
+
<Button
|
|
804
|
+
text='Cancel'
|
|
805
|
+
mode='ghost'
|
|
806
|
+
onClick={() => setIsTranslateAllDialogOpen(false)}
|
|
807
|
+
/>
|
|
808
|
+
<Button
|
|
809
|
+
text='Translate All'
|
|
810
|
+
tone='critical'
|
|
811
|
+
onClick={handleTranslateAll}
|
|
812
|
+
/>
|
|
813
|
+
</Flex>
|
|
814
|
+
</Box>
|
|
815
|
+
}
|
|
816
|
+
>
|
|
817
|
+
<Box padding={4}>
|
|
818
|
+
<Stack space={3}>
|
|
819
|
+
<Text>
|
|
820
|
+
Are you sure you want to create translation tasks for all{' '}
|
|
821
|
+
{documents.length} documents?
|
|
822
|
+
</Text>
|
|
823
|
+
<Text size={1} muted>
|
|
824
|
+
This will submit all documents to General Translation for
|
|
825
|
+
processing.
|
|
826
|
+
</Text>
|
|
827
|
+
</Stack>
|
|
828
|
+
</Box>
|
|
829
|
+
</Dialog>
|
|
830
|
+
)}
|
|
831
|
+
|
|
832
|
+
{/* Import All Confirmation Dialog */}
|
|
833
|
+
{isImportAllDialogOpen && (
|
|
834
|
+
<Dialog
|
|
835
|
+
header='Confirm Import'
|
|
836
|
+
id='import-all-dialog'
|
|
837
|
+
onClose={() => setIsImportAllDialogOpen(false)}
|
|
838
|
+
footer={
|
|
839
|
+
<Box padding={3}>
|
|
840
|
+
<Flex gap={2}>
|
|
841
|
+
<Button
|
|
842
|
+
text='Cancel'
|
|
843
|
+
mode='ghost'
|
|
844
|
+
onClick={() => setIsImportAllDialogOpen(false)}
|
|
845
|
+
/>
|
|
846
|
+
<Button
|
|
847
|
+
text='Import All'
|
|
848
|
+
tone='primary'
|
|
849
|
+
onClick={handleImportAll}
|
|
850
|
+
/>
|
|
851
|
+
</Flex>
|
|
852
|
+
</Box>
|
|
853
|
+
}
|
|
854
|
+
>
|
|
855
|
+
<Box padding={4}>
|
|
856
|
+
<Stack space={3}>
|
|
857
|
+
<Text>
|
|
858
|
+
Are you sure you want to import translations for all{' '}
|
|
859
|
+
{documents.length} documents?
|
|
860
|
+
</Text>
|
|
861
|
+
<Text size={1} muted>
|
|
862
|
+
This will download and apply translated content to your
|
|
863
|
+
documents. Note that this will overwrite any existing
|
|
864
|
+
translations!
|
|
865
|
+
</Text>
|
|
866
|
+
</Stack>
|
|
867
|
+
</Box>
|
|
868
|
+
</Dialog>
|
|
869
|
+
)}
|
|
870
|
+
</Container>
|
|
871
|
+
</ToastProvider>
|
|
872
|
+
</ThemeProvider>
|
|
873
|
+
);
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
export default TranslationsTool;
|