gt-sanity 0.0.5
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 +138 -0
- package/README.md +100 -0
- package/dist/index.d.mts +241 -0
- package/dist/index.d.ts +241 -0
- package/dist/index.js +2119 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2099 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +92 -0
- package/sanity.json +8 -0
- package/src/adapter/core.ts +44 -0
- package/src/adapter/createTask.ts +41 -0
- package/src/adapter/getLocales.ts +13 -0
- package/src/adapter/getTranslation.ts +20 -0
- package/src/adapter/getTranslationTask.ts +31 -0
- package/src/adapter/index.ts +13 -0
- package/src/components/LanguageStatus.tsx +65 -0
- package/src/components/NewTask.tsx +249 -0
- package/src/components/ProgressBar.tsx +38 -0
- package/src/components/TaskView.tsx +255 -0
- package/src/components/TranslationContext.tsx +19 -0
- package/src/components/TranslationView.tsx +82 -0
- package/src/components/TranslationsTab.tsx +177 -0
- package/src/configuration/README.md +8 -0
- package/src/configuration/baseDocumentLevelConfig/documentLevelPatch.ts +108 -0
- package/src/configuration/baseDocumentLevelConfig/helpers/createI18nDocAndPatchMetadata.ts +47 -0
- package/src/configuration/baseDocumentLevelConfig/helpers/createTranslationMetadata.ts +43 -0
- package/src/configuration/baseDocumentLevelConfig/helpers/getOrCreateTranslationMetadata.ts +77 -0
- package/src/configuration/baseDocumentLevelConfig/helpers/getTranslationMetadata.ts +15 -0
- package/src/configuration/baseDocumentLevelConfig/helpers/index.ts +5 -0
- package/src/configuration/baseDocumentLevelConfig/helpers/patchI18nDoc.ts +25 -0
- package/src/configuration/baseDocumentLevelConfig/index.ts +129 -0
- package/src/configuration/baseDocumentLevelConfig/legacyDocumentLevelPatch.ts +69 -0
- package/src/configuration/baseFieldLevelConfig.ts +118 -0
- package/src/configuration/index.ts +18 -0
- package/src/configuration/utils/checkSerializationVersion.ts +13 -0
- package/src/configuration/utils/findDocumentAtRevision.ts +22 -0
- package/src/configuration/utils/findLatestDraft.ts +16 -0
- package/src/configuration/utils/index.ts +3 -0
- package/src/hooks/useClient.ts +5 -0
- package/src/hooks/useSecrets.ts +33 -0
- package/src/index.ts +120 -0
- package/src/types.ts +124 -0
- package/v2-incompatible.js +11 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { useCallback, useContext, useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Button, Flex, Text, Stack, useToast, Switch } from '@sanity/ui';
|
|
3
|
+
import {
|
|
4
|
+
ArrowTopRightIcon,
|
|
5
|
+
DownloadIcon,
|
|
6
|
+
CheckmarkCircleIcon,
|
|
7
|
+
} from '@sanity/icons';
|
|
8
|
+
|
|
9
|
+
import { TranslationContext } from './TranslationContext';
|
|
10
|
+
import { TranslationLocale, TranslationTask } from '../types';
|
|
11
|
+
import { LanguageStatus } from './LanguageStatus';
|
|
12
|
+
|
|
13
|
+
type JobProps = {
|
|
14
|
+
task: TranslationTask;
|
|
15
|
+
locales: TranslationLocale[];
|
|
16
|
+
refreshTask: () => Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const getLocale = (
|
|
20
|
+
localeId: string,
|
|
21
|
+
locales: TranslationLocale[]
|
|
22
|
+
): TranslationLocale | undefined =>
|
|
23
|
+
locales.find((l) => l.localeId === localeId);
|
|
24
|
+
|
|
25
|
+
export const TaskView = ({ task, locales, refreshTask }: JobProps) => {
|
|
26
|
+
const context = useContext(TranslationContext);
|
|
27
|
+
const toast = useToast();
|
|
28
|
+
|
|
29
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
30
|
+
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
31
|
+
const [isBusy, setIsBusy] = useState(false);
|
|
32
|
+
const [autoImport, setAutoImport] = useState(true);
|
|
33
|
+
const [importedFiles, setImportedFiles] = useState<Set<string>>(new Set());
|
|
34
|
+
|
|
35
|
+
const importFile = useCallback(
|
|
36
|
+
async (localeId: string) => {
|
|
37
|
+
if (!context) {
|
|
38
|
+
toast.push({
|
|
39
|
+
title:
|
|
40
|
+
'Missing context, unable to import translation. Try refreshing or clicking away from this tab and back.',
|
|
41
|
+
status: 'error',
|
|
42
|
+
closable: true,
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const locale = getLocale(localeId, locales);
|
|
48
|
+
const localeTitle = locale?.description || localeId;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const translation = await context.adapter.getTranslation(
|
|
52
|
+
task.document,
|
|
53
|
+
localeId,
|
|
54
|
+
context.secrets
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const sanityId = context.localeIdAdapter
|
|
58
|
+
? await context.localeIdAdapter(localeId)
|
|
59
|
+
: localeId;
|
|
60
|
+
|
|
61
|
+
await context.importTranslation(sanityId, translation);
|
|
62
|
+
|
|
63
|
+
setImportedFiles((prev) => new Set([...prev, localeId]));
|
|
64
|
+
|
|
65
|
+
toast.push({
|
|
66
|
+
title: `Imported ${localeTitle} translation`,
|
|
67
|
+
status: 'success',
|
|
68
|
+
closable: true,
|
|
69
|
+
});
|
|
70
|
+
} catch (err) {
|
|
71
|
+
let errorMsg;
|
|
72
|
+
if (err instanceof Error) {
|
|
73
|
+
errorMsg = err.message;
|
|
74
|
+
} else {
|
|
75
|
+
errorMsg = err ? String(err) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
toast.push({
|
|
79
|
+
title: `Error getting ${localeTitle} translation`,
|
|
80
|
+
description: errorMsg,
|
|
81
|
+
status: 'error',
|
|
82
|
+
closable: true,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
[locales, context, task.document, toast]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const checkAndImportCompletedFiles = useCallback(async () => {
|
|
90
|
+
if (!autoImport || isBusy) return;
|
|
91
|
+
|
|
92
|
+
const completedFiles = task.locales.filter(
|
|
93
|
+
(locale) =>
|
|
94
|
+
(locale.progress || 0) >= 100 && !importedFiles.has(locale.localeId)
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (completedFiles.length === 0) return;
|
|
98
|
+
|
|
99
|
+
setIsBusy(true);
|
|
100
|
+
try {
|
|
101
|
+
for (const locale of completedFiles) {
|
|
102
|
+
await importFile(locale.localeId);
|
|
103
|
+
}
|
|
104
|
+
} finally {
|
|
105
|
+
setIsBusy(false);
|
|
106
|
+
}
|
|
107
|
+
}, [autoImport, isBusy, task.locales, importedFiles, importFile]);
|
|
108
|
+
|
|
109
|
+
const handleRefreshClick = useCallback(async () => {
|
|
110
|
+
if (isRefreshing) return;
|
|
111
|
+
setIsRefreshing(true);
|
|
112
|
+
await refreshTask();
|
|
113
|
+
await checkAndImportCompletedFiles();
|
|
114
|
+
setIsRefreshing(false);
|
|
115
|
+
}, [refreshTask, setIsRefreshing, checkAndImportCompletedFiles]);
|
|
116
|
+
|
|
117
|
+
const handleImportAll = useCallback(async () => {
|
|
118
|
+
if (isBusy) return;
|
|
119
|
+
setIsBusy(true);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const filesToImport = task.locales.filter(
|
|
123
|
+
(locale) => !importedFiles.has(locale.localeId)
|
|
124
|
+
);
|
|
125
|
+
for (const locale of filesToImport) {
|
|
126
|
+
await importFile(locale.localeId);
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
setIsBusy(false);
|
|
130
|
+
}
|
|
131
|
+
}, [task.locales, importFile, isBusy, importedFiles]);
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!autoRefresh || importedFiles.size === task.locales.length) return;
|
|
135
|
+
|
|
136
|
+
const interval = setInterval(async () => {
|
|
137
|
+
await handleRefreshClick();
|
|
138
|
+
}, 5000);
|
|
139
|
+
|
|
140
|
+
return () => clearInterval(interval);
|
|
141
|
+
}, [
|
|
142
|
+
handleRefreshClick,
|
|
143
|
+
autoRefresh,
|
|
144
|
+
importedFiles.size,
|
|
145
|
+
task.locales.length,
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
checkAndImportCompletedFiles();
|
|
150
|
+
}, [checkAndImportCompletedFiles, task.locales]);
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
setImportedFiles((prev) => {
|
|
154
|
+
const newSet = new Set<string>();
|
|
155
|
+
for (const localeId of prev) {
|
|
156
|
+
if (task.locales.some((locale) => locale.localeId === localeId)) {
|
|
157
|
+
newSet.add(localeId);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return newSet;
|
|
161
|
+
});
|
|
162
|
+
}, [task.locales]);
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<Stack space={4}>
|
|
166
|
+
<Flex align='center' justify='space-between'>
|
|
167
|
+
<Text as='h2' weight='semibold' size={2}>
|
|
168
|
+
Translation Progress
|
|
169
|
+
</Text>
|
|
170
|
+
|
|
171
|
+
<Flex gap={3} align='center'>
|
|
172
|
+
<Flex gap={2} align='center'>
|
|
173
|
+
<Text size={1}>Auto-refresh</Text>
|
|
174
|
+
<Switch
|
|
175
|
+
checked={autoRefresh}
|
|
176
|
+
onChange={() => setAutoRefresh(!autoRefresh)}
|
|
177
|
+
/>
|
|
178
|
+
</Flex>
|
|
179
|
+
{task.linkToVendorTask && (
|
|
180
|
+
<Button
|
|
181
|
+
as='a'
|
|
182
|
+
text='View Job'
|
|
183
|
+
iconRight={ArrowTopRightIcon}
|
|
184
|
+
href={task.linkToVendorTask}
|
|
185
|
+
target='_blank'
|
|
186
|
+
rel='noreferrer noopener'
|
|
187
|
+
fontSize={1}
|
|
188
|
+
padding={2}
|
|
189
|
+
mode='bleed'
|
|
190
|
+
/>
|
|
191
|
+
)}
|
|
192
|
+
<Button
|
|
193
|
+
fontSize={1}
|
|
194
|
+
padding={2}
|
|
195
|
+
text='Refresh Status'
|
|
196
|
+
onClick={handleRefreshClick}
|
|
197
|
+
disabled={isRefreshing}
|
|
198
|
+
/>
|
|
199
|
+
</Flex>
|
|
200
|
+
</Flex>
|
|
201
|
+
|
|
202
|
+
<Box>
|
|
203
|
+
{task.locales.map((localeTask) => {
|
|
204
|
+
const reportPercent = localeTask.progress || 0;
|
|
205
|
+
const locale = getLocale(localeTask.localeId, locales);
|
|
206
|
+
return (
|
|
207
|
+
<LanguageStatus
|
|
208
|
+
key={[task.document.documentId, localeTask.localeId].join('.')}
|
|
209
|
+
importFile={async () => {
|
|
210
|
+
await importFile(localeTask.localeId);
|
|
211
|
+
}}
|
|
212
|
+
title={locale?.description || localeTask.localeId}
|
|
213
|
+
progress={reportPercent}
|
|
214
|
+
isImported={importedFiles.has(localeTask.localeId)}
|
|
215
|
+
/>
|
|
216
|
+
);
|
|
217
|
+
})}
|
|
218
|
+
</Box>
|
|
219
|
+
<Stack space={3}>
|
|
220
|
+
<Flex gap={3} align='center' justify='space-between'>
|
|
221
|
+
<Flex gap={2} align='center'>
|
|
222
|
+
<Button
|
|
223
|
+
mode='ghost'
|
|
224
|
+
onClick={handleImportAll}
|
|
225
|
+
text={isBusy ? 'Importing...' : 'Import All'}
|
|
226
|
+
icon={isBusy ? null : DownloadIcon}
|
|
227
|
+
disabled={isBusy || importedFiles.size === task.locales.length}
|
|
228
|
+
/>
|
|
229
|
+
{importedFiles.size === task.locales.length &&
|
|
230
|
+
task.locales.length > 0 && (
|
|
231
|
+
<Flex gap={2} align='center' style={{ color: 'green' }}>
|
|
232
|
+
<CheckmarkCircleIcon />
|
|
233
|
+
<Text size={1}>All translations imported</Text>
|
|
234
|
+
</Flex>
|
|
235
|
+
)}
|
|
236
|
+
{importedFiles.size > 0 &&
|
|
237
|
+
importedFiles.size < task.locales.length && (
|
|
238
|
+
<Text size={1} style={{ color: '#666' }}>
|
|
239
|
+
{importedFiles.size}/{task.locales.length} imported
|
|
240
|
+
</Text>
|
|
241
|
+
)}
|
|
242
|
+
</Flex>
|
|
243
|
+
<Flex gap={2} align='center' style={{ whiteSpace: 'nowrap' }}>
|
|
244
|
+
<Text size={1}>Auto-import when complete</Text>
|
|
245
|
+
<Switch
|
|
246
|
+
checked={autoImport}
|
|
247
|
+
onChange={() => setAutoImport(!autoImport)}
|
|
248
|
+
disabled={isBusy}
|
|
249
|
+
/>
|
|
250
|
+
</Flex>
|
|
251
|
+
</Flex>
|
|
252
|
+
</Stack>
|
|
253
|
+
</Stack>
|
|
254
|
+
);
|
|
255
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { GTSerializedDocument } from '../types';
|
|
3
|
+
import { Adapter, GTFile, Secrets, WorkflowIdentifiers } from '../types';
|
|
4
|
+
|
|
5
|
+
export type ContextProps = {
|
|
6
|
+
documentInfo: GTFile;
|
|
7
|
+
adapter: Adapter;
|
|
8
|
+
importTranslation: (languageId: string, document: string) => Promise<void>;
|
|
9
|
+
exportForTranslation: (documentInfo: GTFile) => Promise<GTSerializedDocument>;
|
|
10
|
+
secrets: Secrets;
|
|
11
|
+
workflowOptions?: WorkflowIdentifiers[];
|
|
12
|
+
localeIdAdapter?: (id: string) => string | Promise<string>;
|
|
13
|
+
callbackUrl?: string;
|
|
14
|
+
mergeWithTargetLocale?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const TranslationContext = React.createContext<ContextProps | null>(
|
|
18
|
+
null
|
|
19
|
+
);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Add cleanup function to cancel async tasks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useCallback, useContext, useEffect, useState } from 'react';
|
|
6
|
+
import { Stack, useToast } from '@sanity/ui';
|
|
7
|
+
import { TranslationContext } from './TranslationContext';
|
|
8
|
+
|
|
9
|
+
import { NewTask } from './NewTask';
|
|
10
|
+
import { TaskView } from './TaskView';
|
|
11
|
+
import { TranslationTask, TranslationLocale } from '../types';
|
|
12
|
+
|
|
13
|
+
export const TranslationView = () => {
|
|
14
|
+
const [locales, setLocales] = useState<TranslationLocale[]>([]);
|
|
15
|
+
const [task, setTask] = useState<TranslationTask | null>(null);
|
|
16
|
+
|
|
17
|
+
const context = useContext(TranslationContext);
|
|
18
|
+
const toast = useToast();
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
async function fetchData() {
|
|
22
|
+
if (!context) {
|
|
23
|
+
toast.push({
|
|
24
|
+
title: 'Unable to load translation data: missing context',
|
|
25
|
+
status: 'error',
|
|
26
|
+
closable: true,
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const locales = await context.adapter.getLocales(context.secrets);
|
|
32
|
+
setLocales(locales);
|
|
33
|
+
try {
|
|
34
|
+
const task = await context?.adapter.getTranslationTask(
|
|
35
|
+
context.documentInfo,
|
|
36
|
+
context.secrets
|
|
37
|
+
);
|
|
38
|
+
setTask(task);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
let errorMsg;
|
|
41
|
+
if (err instanceof Error) {
|
|
42
|
+
errorMsg = err.message;
|
|
43
|
+
} else {
|
|
44
|
+
errorMsg = err ? String(err) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Hacky bypass for when a document is not yet translated and has never been uploaded
|
|
48
|
+
if (errorMsg?.toLowerCase().includes('no source file found')) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
toast.push({
|
|
53
|
+
title: `Error creating translation job`,
|
|
54
|
+
description: errorMsg,
|
|
55
|
+
status: 'error',
|
|
56
|
+
closable: true,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fetchData();
|
|
62
|
+
}, [context, toast]);
|
|
63
|
+
|
|
64
|
+
const refreshTask = useCallback(async () => {
|
|
65
|
+
const task = await context?.adapter.getTranslationTask(
|
|
66
|
+
context.documentInfo,
|
|
67
|
+
context.secrets
|
|
68
|
+
);
|
|
69
|
+
if (task) {
|
|
70
|
+
setTask(task);
|
|
71
|
+
}
|
|
72
|
+
}, [context, setTask]);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Stack space={6}>
|
|
76
|
+
<NewTask locales={locales} refreshTask={refreshTask} />
|
|
77
|
+
{task && (
|
|
78
|
+
<TaskView task={task} locales={locales} refreshTask={refreshTask} />
|
|
79
|
+
)}
|
|
80
|
+
</Stack>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { SanityDocument, useSchema } from 'sanity';
|
|
3
|
+
import { randomKey } from '@sanity/util/content';
|
|
4
|
+
import {
|
|
5
|
+
ThemeProvider,
|
|
6
|
+
ToastProvider,
|
|
7
|
+
Stack,
|
|
8
|
+
Text,
|
|
9
|
+
Layer,
|
|
10
|
+
Box,
|
|
11
|
+
Card,
|
|
12
|
+
Flex,
|
|
13
|
+
Spinner,
|
|
14
|
+
} from '@sanity/ui';
|
|
15
|
+
|
|
16
|
+
import { TranslationContext } from './TranslationContext';
|
|
17
|
+
import { TranslationView } from './TranslationView';
|
|
18
|
+
import { useClient } from '../hooks/useClient';
|
|
19
|
+
import { useSecrets } from '../hooks/useSecrets';
|
|
20
|
+
import { GTFile, Secrets, TranslationsTabConfigOptions } from '../types';
|
|
21
|
+
|
|
22
|
+
type TranslationTabProps = {
|
|
23
|
+
document: {
|
|
24
|
+
displayed: SanityDocument;
|
|
25
|
+
};
|
|
26
|
+
options: TranslationsTabConfigOptions;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const TranslationTab = (props: TranslationTabProps) => {
|
|
30
|
+
const { displayed } = props.document;
|
|
31
|
+
const client = useClient();
|
|
32
|
+
const schema = useSchema();
|
|
33
|
+
|
|
34
|
+
const documentId =
|
|
35
|
+
displayed && displayed._id
|
|
36
|
+
? (displayed._id.split('drafts.').pop() as string)
|
|
37
|
+
: '';
|
|
38
|
+
|
|
39
|
+
const revisionId = displayed && displayed._rev ? displayed._rev : undefined;
|
|
40
|
+
|
|
41
|
+
const { errors, importTranslation, exportForTranslation } = useMemo(() => {
|
|
42
|
+
const { serializationOptions, languageField, mergeWithTargetLocale } =
|
|
43
|
+
props.options;
|
|
44
|
+
const ctx = {
|
|
45
|
+
client,
|
|
46
|
+
schema,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const allErrors = [];
|
|
50
|
+
|
|
51
|
+
const importTranslationFunc = props.options.importTranslation;
|
|
52
|
+
if (!importTranslationFunc) {
|
|
53
|
+
allErrors.push({
|
|
54
|
+
key: randomKey(12),
|
|
55
|
+
text: (
|
|
56
|
+
<>
|
|
57
|
+
You need to provide an <code>importTranslation</code> function. See
|
|
58
|
+
documentation.
|
|
59
|
+
</>
|
|
60
|
+
),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const contextImportTranslation = (localeId: string, doc: string) => {
|
|
65
|
+
return importTranslationFunc(
|
|
66
|
+
{ documentId, versionId: revisionId },
|
|
67
|
+
localeId,
|
|
68
|
+
doc,
|
|
69
|
+
ctx,
|
|
70
|
+
serializationOptions,
|
|
71
|
+
languageField,
|
|
72
|
+
mergeWithTargetLocale
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const exportTranslationFunc = props.options.exportForTranslation;
|
|
77
|
+
if (!exportTranslationFunc) {
|
|
78
|
+
allErrors.push({
|
|
79
|
+
key: randomKey(12),
|
|
80
|
+
text: (
|
|
81
|
+
<>
|
|
82
|
+
You need to provide an <code>exportForTranslation</code> function.
|
|
83
|
+
See documentation.
|
|
84
|
+
</>
|
|
85
|
+
),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const contextExportForTranslation = (docInfo: GTFile) => {
|
|
90
|
+
return exportTranslationFunc(
|
|
91
|
+
docInfo,
|
|
92
|
+
ctx,
|
|
93
|
+
serializationOptions,
|
|
94
|
+
languageField
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
errors: allErrors,
|
|
100
|
+
importTranslation: contextImportTranslation,
|
|
101
|
+
exportForTranslation: contextExportForTranslation,
|
|
102
|
+
};
|
|
103
|
+
}, [props.options, documentId, revisionId, client, schema]);
|
|
104
|
+
|
|
105
|
+
const { loading, secrets } = useSecrets<Secrets>(
|
|
106
|
+
`${props.options.secretsNamespace || 'translationService'}.secrets`
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const hasErrors = errors.length > 0;
|
|
110
|
+
|
|
111
|
+
if (loading || !secrets) {
|
|
112
|
+
return (
|
|
113
|
+
<ThemeProvider>
|
|
114
|
+
<Flex padding={5} align='center' justify='center'>
|
|
115
|
+
<Spinner />
|
|
116
|
+
</Flex>
|
|
117
|
+
</ThemeProvider>
|
|
118
|
+
);
|
|
119
|
+
} else if (!secrets) {
|
|
120
|
+
return (
|
|
121
|
+
<ThemeProvider>
|
|
122
|
+
<Box padding={4}>
|
|
123
|
+
<Card tone='caution' padding={[2, 3, 4, 4]} shadow={1} radius={2}>
|
|
124
|
+
<Text>
|
|
125
|
+
Can't find secrets for your translation service. Did you load them
|
|
126
|
+
into this dataset?
|
|
127
|
+
</Text>
|
|
128
|
+
</Card>
|
|
129
|
+
</Box>
|
|
130
|
+
</ThemeProvider>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return (
|
|
134
|
+
<ThemeProvider>
|
|
135
|
+
<Box padding={4}>
|
|
136
|
+
<Layer>
|
|
137
|
+
<ToastProvider paddingY={7}>
|
|
138
|
+
{hasErrors && (
|
|
139
|
+
<Stack space={3}>
|
|
140
|
+
{errors.map((error) => (
|
|
141
|
+
<Card
|
|
142
|
+
key={error.key}
|
|
143
|
+
tone='caution'
|
|
144
|
+
padding={[2, 3, 4, 4]}
|
|
145
|
+
shadow={1}
|
|
146
|
+
radius={2}
|
|
147
|
+
>
|
|
148
|
+
<Text>{error.text}</Text>
|
|
149
|
+
</Card>
|
|
150
|
+
))}
|
|
151
|
+
</Stack>
|
|
152
|
+
)}
|
|
153
|
+
{!hasErrors && (
|
|
154
|
+
<TranslationContext.Provider
|
|
155
|
+
value={{
|
|
156
|
+
documentInfo: { documentId, versionId: revisionId },
|
|
157
|
+
secrets,
|
|
158
|
+
importTranslation,
|
|
159
|
+
exportForTranslation,
|
|
160
|
+
adapter: props.options.adapter,
|
|
161
|
+
workflowOptions: props.options.workflowOptions,
|
|
162
|
+
localeIdAdapter: props.options.localeIdAdapter,
|
|
163
|
+
callbackUrl: props.options.callbackUrl,
|
|
164
|
+
mergeWithTargetLocale: props.options.mergeWithTargetLocale,
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<TranslationView />
|
|
168
|
+
</TranslationContext.Provider>
|
|
169
|
+
)}
|
|
170
|
+
</ToastProvider>
|
|
171
|
+
</Layer>
|
|
172
|
+
</Box>
|
|
173
|
+
</ThemeProvider>
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export default TranslationTab;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
This folder holds the default configurations to be provided as options to the Translations Tab, namely:
|
|
4
|
+
|
|
5
|
+
- `exportForTranslation`: Process a Sanity document before sending it off to a TMS. By default, the documents will be serialized to HTML: content will be rendered in nested divs in the HTML `body`, and any relevant metadata will be in the HTML `head`.
|
|
6
|
+
For more information on this process, please refer to the [Sanity Naive HTML Serializer](https://github.com/sanity-io/sanity-naive-html-serializer)
|
|
7
|
+
|
|
8
|
+
- `importTranslation`: Receive a translated document back from a TMS, parse it into a Sanity-readable format, and patch it back to the relevant Sanity document. Again, the [Sanity Naive HTML Serializer](https://github.com/sanity-io/sanity-naive-html-serializer) is used for parsing under the assumption that the file sent over was HTML, but any function could be used here.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { SanityClient, SanityDocument, SanityDocumentLike } from 'sanity';
|
|
2
|
+
import { BaseDocumentMerger } from 'sanity-naive-html-serializer';
|
|
3
|
+
|
|
4
|
+
import { findLatestDraft, findDocumentAtRevision } from '../utils';
|
|
5
|
+
import {
|
|
6
|
+
createI18nDocAndPatchMetadata,
|
|
7
|
+
getOrCreateTranslationMetadata,
|
|
8
|
+
patchI18nDoc,
|
|
9
|
+
} from './helpers';
|
|
10
|
+
import type { GTFile } from '../../types';
|
|
11
|
+
import { gtConfig } from '../../adapter/core';
|
|
12
|
+
|
|
13
|
+
export const documentLevelPatch = async (
|
|
14
|
+
docInfo: GTFile,
|
|
15
|
+
translatedFields: SanityDocument,
|
|
16
|
+
localeId: string,
|
|
17
|
+
client: SanityClient,
|
|
18
|
+
languageField: string = 'language',
|
|
19
|
+
mergeWithTargetLocale: boolean = false
|
|
20
|
+
): Promise<void> => {
|
|
21
|
+
const baseLanguage = gtConfig.getSourceLocale();
|
|
22
|
+
//this is the document we use to merge with the translated fields
|
|
23
|
+
let baseDoc: SanityDocument | null = null;
|
|
24
|
+
|
|
25
|
+
//this is the document that will serve as the translated doc
|
|
26
|
+
let i18nDoc: SanityDocument | null = null;
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* we send over the _rev with our translation file so we can
|
|
30
|
+
* accurately coalesce the translations in case something has
|
|
31
|
+
* changed in the base document since translating
|
|
32
|
+
*/
|
|
33
|
+
if (docInfo.documentId && docInfo.versionId) {
|
|
34
|
+
baseDoc = await findDocumentAtRevision(
|
|
35
|
+
docInfo.documentId,
|
|
36
|
+
docInfo.versionId,
|
|
37
|
+
client
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (!baseDoc) {
|
|
41
|
+
baseDoc = await findLatestDraft(docInfo.documentId, client);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* first, check our metadata to see if a translated document exists
|
|
45
|
+
* if no metadata exists, we create it atomically
|
|
46
|
+
*/
|
|
47
|
+
const translationMetadata = await getOrCreateTranslationMetadata(
|
|
48
|
+
docInfo.documentId,
|
|
49
|
+
baseDoc,
|
|
50
|
+
client,
|
|
51
|
+
baseLanguage
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
//the id of the translated document should be on the metadata if it exists
|
|
55
|
+
const i18nDocId = (
|
|
56
|
+
translationMetadata.translations as Array<Record<string, any>>
|
|
57
|
+
).find((translation) => translation._key === localeId)?.value?._ref;
|
|
58
|
+
|
|
59
|
+
if (i18nDocId) {
|
|
60
|
+
//get draft or published
|
|
61
|
+
i18nDoc = await findLatestDraft(i18nDocId, client);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
//if the user has chosen to merge with the target locale,
|
|
65
|
+
//any existing target document will serve as our base document
|
|
66
|
+
if (mergeWithTargetLocale && i18nDoc) {
|
|
67
|
+
baseDoc = i18nDoc;
|
|
68
|
+
} else if (docInfo.documentId && docInfo.versionId) {
|
|
69
|
+
/*
|
|
70
|
+
* we send over the _rev with our translation file so we can
|
|
71
|
+
* accurately coalesce the translations in case something has
|
|
72
|
+
* changed in the base document since translating
|
|
73
|
+
*/
|
|
74
|
+
baseDoc = await findDocumentAtRevision(
|
|
75
|
+
docInfo.documentId,
|
|
76
|
+
docInfo.versionId,
|
|
77
|
+
client
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!baseDoc) {
|
|
82
|
+
baseDoc = await findLatestDraft(docInfo.documentId, client);
|
|
83
|
+
}
|
|
84
|
+
/*
|
|
85
|
+
* we then merge the translation with the base document
|
|
86
|
+
* to create a document that contains the translation and everything
|
|
87
|
+
* that wasn't sent over for translation
|
|
88
|
+
*/
|
|
89
|
+
const merged = BaseDocumentMerger.documentLevelMerge(
|
|
90
|
+
translatedFields,
|
|
91
|
+
baseDoc
|
|
92
|
+
) as SanityDocumentLike;
|
|
93
|
+
|
|
94
|
+
if (i18nDoc) {
|
|
95
|
+
patchI18nDoc(docInfo.documentId, merged, translatedFields, client);
|
|
96
|
+
}
|
|
97
|
+
//otherwise, create a new document
|
|
98
|
+
//and add the document reference to the metadata document
|
|
99
|
+
else {
|
|
100
|
+
createI18nDocAndPatchMetadata(
|
|
101
|
+
merged,
|
|
102
|
+
localeId,
|
|
103
|
+
client,
|
|
104
|
+
translationMetadata,
|
|
105
|
+
languageField
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { SanityClient, SanityDocumentLike } from 'sanity';
|
|
2
|
+
|
|
3
|
+
export const createI18nDocAndPatchMetadata = (
|
|
4
|
+
translatedDoc: SanityDocumentLike,
|
|
5
|
+
localeId: string,
|
|
6
|
+
client: SanityClient,
|
|
7
|
+
translationMetadata: SanityDocumentLike,
|
|
8
|
+
languageField: string = 'language'
|
|
9
|
+
): void => {
|
|
10
|
+
translatedDoc[languageField] = localeId;
|
|
11
|
+
const translations = translationMetadata.translations as Record<
|
|
12
|
+
string,
|
|
13
|
+
any
|
|
14
|
+
>[];
|
|
15
|
+
const existingLocaleKey = translations.find(
|
|
16
|
+
(translation) => translation._key === localeId
|
|
17
|
+
);
|
|
18
|
+
const operation = existingLocaleKey ? 'replace' : 'after';
|
|
19
|
+
const location = existingLocaleKey
|
|
20
|
+
? `translations[_key == "${localeId}"]`
|
|
21
|
+
: 'translations[-1]';
|
|
22
|
+
|
|
23
|
+
//remove system fields
|
|
24
|
+
const { _updatedAt, _createdAt, ...rest } = translatedDoc;
|
|
25
|
+
client.create({ ...rest, _id: 'drafts.' }).then((doc) => {
|
|
26
|
+
const _ref = doc._id.replace('drafts.', '');
|
|
27
|
+
client
|
|
28
|
+
.transaction()
|
|
29
|
+
.patch(translationMetadata._id, (p) =>
|
|
30
|
+
p.insert(operation, location, [
|
|
31
|
+
{
|
|
32
|
+
_key: localeId,
|
|
33
|
+
_type: 'internationalizedArrayReferenceValue',
|
|
34
|
+
value: {
|
|
35
|
+
_type: 'reference',
|
|
36
|
+
_ref,
|
|
37
|
+
_weak: true,
|
|
38
|
+
_strengthenOnPublish: {
|
|
39
|
+
type: doc._type,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
])
|
|
44
|
+
)
|
|
45
|
+
.commit();
|
|
46
|
+
});
|
|
47
|
+
};
|