@braze/sanity-plugin-ai-translation 0.1.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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +2225 -0
- package/dist/index.js.map +1 -0
- package/package.json +105 -0
- package/sanity.json +8 -0
- package/v2-incompatible.js +11 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2225 @@
|
|
|
1
|
+
import { useClient, defineType, defineField, pathToString, definePlugin } from "sanity";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ErrorOutlineIcon, CheckmarkCircleIcon, EmptyIcon, AddIcon, TrashIcon, ArrowLeftIcon, EditIcon, InfoOutlineIcon, TranslateIcon, CheckmarkIcon, CircleIcon, ArrowRightIcon, PanelRightIcon } from "@sanity/icons";
|
|
4
|
+
import { createContext, useContext, useEffect, useState, useReducer, useMemo } from "react";
|
|
5
|
+
import { Card, Flex, Stack, Text, Spinner, Button, Box, Checkbox, Select, TextInput, TextArea, Heading, Inline, Tooltip, Badge, Grid, Label, Code } from "@sanity/ui";
|
|
6
|
+
import { useListeningQuery, useOpenInNewPane } from "sanity-plugin-utils";
|
|
7
|
+
import defaults from "lodash.defaults";
|
|
8
|
+
import { diffPatch } from "@sanity/diff-patch";
|
|
9
|
+
const TranslationContext = createContext({
|
|
10
|
+
document: null,
|
|
11
|
+
config: null,
|
|
12
|
+
overallStatus: "idle",
|
|
13
|
+
selectedTargetLanguages: [],
|
|
14
|
+
translationJobs: /* @__PURE__ */ new Map(),
|
|
15
|
+
shouldTranslateDiffs: !1,
|
|
16
|
+
shouldOverwriteFreshTranslations: !1,
|
|
17
|
+
shouldTranslateReferences: !1,
|
|
18
|
+
maxReferenceDepth: 1,
|
|
19
|
+
references: [],
|
|
20
|
+
translationMetadata: null,
|
|
21
|
+
dispatch: () => {
|
|
22
|
+
}
|
|
23
|
+
}), useTranslationContext = () => {
|
|
24
|
+
const context = useContext(TranslationContext);
|
|
25
|
+
if (!context.document || !context.config)
|
|
26
|
+
throw new Error(
|
|
27
|
+
"useTranslationContext must be used within a TranslationContextProvider with document and config props"
|
|
28
|
+
);
|
|
29
|
+
return context;
|
|
30
|
+
}, TRANSLATION_METADATA_TYPE = "translation.metadata", UPDATE_EXISTING_TRANSLATION_PROMPT = "***IMPORTANT:** This document has an existing, but out-of-date translation. Please review the existing translation before processing and change as little as possible. Existing translation: $extantTranslation", undraftId = (id) => id.replace(/^drafts\./, ""), draftId = (id) => `drafts.${undraftId(id)}`, TRANSLATION_METADATA_QUERY = "*[_type == $translationMetadataType && references($documentId)][0]", useTranslationMetadata = ({ documentId }) => {
|
|
31
|
+
const { dispatch } = useTranslationContext(), { data, loading, error } = useListeningQuery(TRANSLATION_METADATA_QUERY, {
|
|
32
|
+
params: {
|
|
33
|
+
documentId: undraftId(documentId),
|
|
34
|
+
translationMetadataType: TRANSLATION_METADATA_TYPE
|
|
35
|
+
},
|
|
36
|
+
initialValue: null
|
|
37
|
+
});
|
|
38
|
+
return useEffect(() => {
|
|
39
|
+
if (loading) {
|
|
40
|
+
dispatch({ type: "SET_OVERALL_STATUS", payload: "fetching_metadata" });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (error) {
|
|
44
|
+
dispatch({ type: "SET_OVERALL_STATUS", payload: "error" });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (data) {
|
|
48
|
+
dispatch({ type: "SET_TRANSLATION_METADATA", payload: data }), dispatch({ type: "SET_OVERALL_STATUS", payload: "idle" });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
dispatch({ type: "SET_OVERALL_STATUS", payload: "idle" });
|
|
52
|
+
}, [data, loading, error, dispatch]), {
|
|
53
|
+
data,
|
|
54
|
+
loading,
|
|
55
|
+
error
|
|
56
|
+
};
|
|
57
|
+
}, isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLATION_PLUGIN_LOGGING === "true", devLog = {
|
|
58
|
+
info: (message, context) => {
|
|
59
|
+
isLoggingEnabled() && console.info("[AI Translation]", message, context ? JSON.stringify(context, null, 2) : "");
|
|
60
|
+
},
|
|
61
|
+
warn: (message, context) => {
|
|
62
|
+
isLoggingEnabled() && console.warn("[AI Translation]", message, context ? JSON.stringify(context, null, 2) : "");
|
|
63
|
+
},
|
|
64
|
+
error: (message, error, context) => {
|
|
65
|
+
isLoggingEnabled() && console.error("[AI Translation]", message, error, context ? JSON.stringify(context, null, 2) : "");
|
|
66
|
+
},
|
|
67
|
+
debug: (message, data, context) => {
|
|
68
|
+
isLoggingEnabled() && console.debug("[AI Translation]", message, data, context ? JSON.stringify(context, null, 2) : "");
|
|
69
|
+
},
|
|
70
|
+
group: (label, context) => {
|
|
71
|
+
isLoggingEnabled() && console.group(`[AI Translation] ${label}`, context ? JSON.stringify(context, null, 2) : "");
|
|
72
|
+
},
|
|
73
|
+
groupEnd: () => {
|
|
74
|
+
isLoggingEnabled() && console.groupEnd();
|
|
75
|
+
},
|
|
76
|
+
time: (label) => {
|
|
77
|
+
isLoggingEnabled() && console.time(`[AI Translation] ${label}`);
|
|
78
|
+
},
|
|
79
|
+
timeEnd: (label) => {
|
|
80
|
+
isLoggingEnabled() && console.timeEnd(`[AI Translation] ${label}`);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
async function analyzeFeedback({
|
|
84
|
+
client,
|
|
85
|
+
sourceLanguage,
|
|
86
|
+
targetLanguage,
|
|
87
|
+
userFeedback,
|
|
88
|
+
shouldIncludeEdits,
|
|
89
|
+
sourceSnapshot,
|
|
90
|
+
targetSnapshot,
|
|
91
|
+
currentDocument,
|
|
92
|
+
onProgress
|
|
93
|
+
}) {
|
|
94
|
+
devLog.info("Starting feedback analysis", {
|
|
95
|
+
sourceLanguage,
|
|
96
|
+
targetLanguage,
|
|
97
|
+
hasUserFeedback: !!userFeedback,
|
|
98
|
+
shouldIncludeEdits
|
|
99
|
+
}), onProgress?.({ stage: "analyzing", message: "Analyzing feedback and edits..." });
|
|
100
|
+
const analysisPrompt = buildAnalysisPrompt({
|
|
101
|
+
sourceLanguage,
|
|
102
|
+
targetLanguage,
|
|
103
|
+
userFeedback,
|
|
104
|
+
shouldIncludeEdits,
|
|
105
|
+
sourceSnapshot,
|
|
106
|
+
targetSnapshot,
|
|
107
|
+
currentDocument
|
|
108
|
+
}), analysis = await analyzeWithAI(client, analysisPrompt);
|
|
109
|
+
devLog.info("AI analysis complete", { analysis });
|
|
110
|
+
const suggestions = convertAnalysisToSuggestions(analysis);
|
|
111
|
+
return devLog.info("Generated suggestions", { count: suggestions.length }), suggestions;
|
|
112
|
+
}
|
|
113
|
+
async function applySuggestions({
|
|
114
|
+
client,
|
|
115
|
+
schemaId,
|
|
116
|
+
sourceLanguage,
|
|
117
|
+
targetLanguage,
|
|
118
|
+
suggestions,
|
|
119
|
+
onProgress
|
|
120
|
+
}) {
|
|
121
|
+
devLog.info("Applying suggestions", {
|
|
122
|
+
sourceLanguage,
|
|
123
|
+
targetLanguage,
|
|
124
|
+
count: suggestions.filter((s) => s.approved).length
|
|
125
|
+
});
|
|
126
|
+
const approvedSuggestions = suggestions.filter((s) => s.approved);
|
|
127
|
+
if (approvedSuggestions.length === 0) {
|
|
128
|
+
devLog.info("No approved suggestions to apply");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
onProgress?.({ stage: "applying", message: "Applying updates to translation settings..." }), await applyImprovementsFromSuggestions(client, schemaId, sourceLanguage, approvedSuggestions, onProgress), devLog.info("Suggestions applied successfully");
|
|
132
|
+
}
|
|
133
|
+
function buildAnalysisPrompt({
|
|
134
|
+
sourceLanguage,
|
|
135
|
+
targetLanguage,
|
|
136
|
+
userFeedback,
|
|
137
|
+
shouldIncludeEdits,
|
|
138
|
+
sourceSnapshot,
|
|
139
|
+
targetSnapshot,
|
|
140
|
+
currentDocument
|
|
141
|
+
}) {
|
|
142
|
+
const parts = [
|
|
143
|
+
"You are analyzing feedback about AI translation quality to improve future translations.",
|
|
144
|
+
`Source language: ${sourceLanguage}`,
|
|
145
|
+
`Target language: ${targetLanguage}`,
|
|
146
|
+
"",
|
|
147
|
+
"Your task is to analyze the feedback and infer specific improvements to:",
|
|
148
|
+
"1. Global translation prompt (general guidance for all translations)",
|
|
149
|
+
"2. Locale-specific prompt (guidance specific to this target language)",
|
|
150
|
+
"3. Protected phrases (terms that should never be translated)",
|
|
151
|
+
"4. Glossary terms (specific term-to-term translation mappings)",
|
|
152
|
+
""
|
|
153
|
+
];
|
|
154
|
+
return userFeedback && parts.push("USER FEEDBACK:", userFeedback, ""), shouldIncludeEdits && sourceSnapshot && targetSnapshot && currentDocument && parts.push(
|
|
155
|
+
"EDIT ANALYSIS:",
|
|
156
|
+
"Compare the original translation with the edited version to understand what the user changed.",
|
|
157
|
+
"",
|
|
158
|
+
"Original source document:",
|
|
159
|
+
sourceSnapshot,
|
|
160
|
+
"",
|
|
161
|
+
"Original AI translation:",
|
|
162
|
+
targetSnapshot,
|
|
163
|
+
"",
|
|
164
|
+
"Current edited document:",
|
|
165
|
+
JSON.stringify(currentDocument, null, 2),
|
|
166
|
+
""
|
|
167
|
+
), parts.push(
|
|
168
|
+
"INSTRUCTIONS:",
|
|
169
|
+
"Analyze the feedback and/or edits to identify patterns and improvements.",
|
|
170
|
+
"",
|
|
171
|
+
"For edits, look for:",
|
|
172
|
+
"- Terminology changes that suggest glossary additions",
|
|
173
|
+
"- Phrases that were protected/unprotected incorrectly",
|
|
174
|
+
"- Style, tone, or formatting issues that suggest prompt improvements",
|
|
175
|
+
"- Consistent patterns across multiple changes",
|
|
176
|
+
"",
|
|
177
|
+
"IMPORTANT RULES:",
|
|
178
|
+
"1. DO NOT create redundant updates. Choose ONE mechanism for each issue:",
|
|
179
|
+
" - Protected phrases = terms that should NEVER be translated (language-agnostic)",
|
|
180
|
+
' - Glossary terms = specific translation mappings (e.g., "chat" \u2192 "cat" in French)',
|
|
181
|
+
" - Locale prompts = guidance specific to this source\u2192target language pair",
|
|
182
|
+
" - Global prompts = guidance that applies to ALL translations",
|
|
183
|
+
"",
|
|
184
|
+
"2. Be locale-specific:",
|
|
185
|
+
` - This feedback is specifically about ${sourceLanguage} \u2192 ${targetLanguage} translation`,
|
|
186
|
+
" - DO NOT assume rules apply to other language pairs unless explicitly stated",
|
|
187
|
+
" - Prefer locale-specific prompts over global prompts unless universally applicable",
|
|
188
|
+
"",
|
|
189
|
+
"3. Protected phrases vs. Locale prompts:",
|
|
190
|
+
" - Use protected phrases ONLY for brand names, product names, proper nouns, technical terms that should NEVER be translated in ANY language",
|
|
191
|
+
' - Use locale prompts for language-pair-specific guidance (e.g., "Keep technical terms in English when translating to German")',
|
|
192
|
+
" - NEVER mention specific terms in both protected phrases AND prompts",
|
|
193
|
+
"",
|
|
194
|
+
"4. Glossary vs. Protected phrases:",
|
|
195
|
+
' - Glossary = "Always translate X as Y" (provides the correct translation)',
|
|
196
|
+
' - Protected = "Never translate X" (keeps it as-is)',
|
|
197
|
+
" - CRITICAL: If sourceValue === targetValue, use PROTECTED PHRASES, NOT glossary",
|
|
198
|
+
" - Only use glossary when the term actually changes between languages",
|
|
199
|
+
" - Use protected when the term should remain unchanged across all languages",
|
|
200
|
+
"",
|
|
201
|
+
"Output a JSON object with this structure:",
|
|
202
|
+
"{",
|
|
203
|
+
' "globalPromptUpdates": "string | null - ONLY if this applies to ALL language pairs",',
|
|
204
|
+
` "localePromptUpdates": "string | null - ONLY if this is specific to ${sourceLanguage}\u2192${targetLanguage}",`,
|
|
205
|
+
' "protectedPhrasesToAdd": ["ONLY brand names, products, proper nouns that should NEVER translate"],',
|
|
206
|
+
' "glossaryTermsToAdd": [{"sourceValue": "term", "targetValue": "translation"} - ONLY specific term mappings],',
|
|
207
|
+
' "reasoning": "string - Explain your analysis and which mechanism you chose for each issue"',
|
|
208
|
+
"}",
|
|
209
|
+
"",
|
|
210
|
+
"Be conservative: only suggest changes when there is clear evidence.",
|
|
211
|
+
"If in doubt, prefer locale-specific updates over global updates.",
|
|
212
|
+
"Avoid redundancy - if you add a protected phrase, do NOT mention it in prompts."
|
|
213
|
+
), parts.join(`
|
|
214
|
+
`);
|
|
215
|
+
}
|
|
216
|
+
function convertAnalysisToSuggestions(analysis) {
|
|
217
|
+
const suggestions = [];
|
|
218
|
+
if (analysis.globalPromptUpdates && suggestions.push({
|
|
219
|
+
id: crypto.randomUUID(),
|
|
220
|
+
type: "globalPrompt",
|
|
221
|
+
description: analysis.globalPromptUpdates,
|
|
222
|
+
approved: !0
|
|
223
|
+
}), analysis.localePromptUpdates && suggestions.push({
|
|
224
|
+
id: crypto.randomUUID(),
|
|
225
|
+
type: "localePrompt",
|
|
226
|
+
description: analysis.localePromptUpdates,
|
|
227
|
+
approved: !0
|
|
228
|
+
}), analysis.protectedPhrasesToAdd)
|
|
229
|
+
for (const phrase of analysis.protectedPhrasesToAdd)
|
|
230
|
+
suggestions.push({
|
|
231
|
+
id: crypto.randomUUID(),
|
|
232
|
+
type: "protectedPhrase",
|
|
233
|
+
description: phrase,
|
|
234
|
+
approved: !0
|
|
235
|
+
});
|
|
236
|
+
if (analysis.glossaryTermsToAdd)
|
|
237
|
+
for (const term of analysis.glossaryTermsToAdd)
|
|
238
|
+
suggestions.push({
|
|
239
|
+
id: crypto.randomUUID(),
|
|
240
|
+
type: "glossaryTerm",
|
|
241
|
+
description: `${term.sourceValue} \u2192 ${term.targetValue}`,
|
|
242
|
+
approved: !0,
|
|
243
|
+
sourceValue: term.sourceValue,
|
|
244
|
+
targetValue: term.targetValue
|
|
245
|
+
});
|
|
246
|
+
return suggestions;
|
|
247
|
+
}
|
|
248
|
+
async function analyzeWithAI(client, prompt) {
|
|
249
|
+
try {
|
|
250
|
+
const response = await client.agent.action.prompt({
|
|
251
|
+
instruction: prompt,
|
|
252
|
+
format: "json"
|
|
253
|
+
});
|
|
254
|
+
devLog.debug("AI response received", { responseType: typeof response });
|
|
255
|
+
const responseText = typeof response == "string" ? response : response && typeof response == "object" ? JSON.stringify(response) : (() => {
|
|
256
|
+
throw new Error(`Unexpected response type: ${typeof response}`);
|
|
257
|
+
})();
|
|
258
|
+
return parseAIResponse(responseText);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
throw devLog.error("Error during AI analysis", { error }), new Error(`Failed to analyze feedback: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function parseAIResponse(responseText) {
|
|
264
|
+
try {
|
|
265
|
+
let parsed;
|
|
266
|
+
try {
|
|
267
|
+
parsed = JSON.parse(responseText);
|
|
268
|
+
} catch {
|
|
269
|
+
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
|
270
|
+
if (!jsonMatch) throw new Error("No JSON found in AI response");
|
|
271
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
272
|
+
}
|
|
273
|
+
if (!parsed || typeof parsed != "object")
|
|
274
|
+
throw new Error("Response is not a valid object");
|
|
275
|
+
const analysis = parsed;
|
|
276
|
+
if (typeof analysis.reasoning != "string")
|
|
277
|
+
throw new Error("Invalid response structure: missing reasoning");
|
|
278
|
+
return {
|
|
279
|
+
globalPromptUpdates: analysis.globalPromptUpdates || void 0,
|
|
280
|
+
localePromptUpdates: analysis.localePromptUpdates || void 0,
|
|
281
|
+
protectedPhrasesToAdd: Array.isArray(analysis.protectedPhrasesToAdd) ? analysis.protectedPhrasesToAdd : [],
|
|
282
|
+
glossaryTermsToAdd: Array.isArray(analysis.glossaryTermsToAdd) ? analysis.glossaryTermsToAdd : [],
|
|
283
|
+
reasoning: analysis.reasoning
|
|
284
|
+
};
|
|
285
|
+
} catch (error) {
|
|
286
|
+
throw devLog.error("Error parsing AI response", { error, responseText }), new Error("Failed to parse AI response as JSON");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async function applyImprovementsFromSuggestions(client, sourceLanguage, targetLanguage, suggestions, onProgress) {
|
|
290
|
+
devLog.info("Applying improvements from suggestions", { count: suggestions.length });
|
|
291
|
+
const settingsId = "translation-settings";
|
|
292
|
+
await client.fetch("*[_id == $id][0]", { id: settingsId }) || await client.createIfNotExists({
|
|
293
|
+
_id: settingsId,
|
|
294
|
+
_type: "translationSettings"
|
|
295
|
+
});
|
|
296
|
+
const updates = [], globalPrompts = suggestions.filter((s) => s.type === "globalPrompt" && s.description.trim()), localePrompts = suggestions.filter((s) => s.type === "localePrompt" && s.description.trim()), protectedPhrases = suggestions.filter((s) => s.type === "protectedPhrase" && s.description.trim()), glossaryTerms = suggestions.filter(
|
|
297
|
+
(s) => s.type === "glossaryTerm" && s.sourceValue?.trim() && s.targetValue?.trim()
|
|
298
|
+
);
|
|
299
|
+
if (devLog.info("Filtered suggestions by type", {
|
|
300
|
+
globalPrompts: globalPrompts.length,
|
|
301
|
+
localePrompts: localePrompts.length,
|
|
302
|
+
protectedPhrases: protectedPhrases.length,
|
|
303
|
+
glossaryTerms: glossaryTerms.length
|
|
304
|
+
}), globalPrompts.length > 0) {
|
|
305
|
+
onProgress?.({ stage: "applying", message: "Updating global translation prompt..." });
|
|
306
|
+
const combinedUpdate = globalPrompts.map((s) => s.description).join(`
|
|
307
|
+
|
|
308
|
+
`);
|
|
309
|
+
devLog.debug("Applying global prompt update", { combinedUpdate }), updates.push(updateGlobalPrompt(client, settingsId, combinedUpdate));
|
|
310
|
+
}
|
|
311
|
+
if (localePrompts.length > 0) {
|
|
312
|
+
onProgress?.({ stage: "applying", message: `Updating ${targetLanguage} translation prompt...` });
|
|
313
|
+
const combinedUpdate = localePrompts.map((s) => s.description).join(`
|
|
314
|
+
|
|
315
|
+
`);
|
|
316
|
+
devLog.debug("Applying locale prompt update", { targetLanguage, combinedUpdate }), updates.push(updateLocalePrompt(client, settingsId, targetLanguage, combinedUpdate));
|
|
317
|
+
}
|
|
318
|
+
if (protectedPhrases.length > 0) {
|
|
319
|
+
onProgress?.({ stage: "applying", message: "Adding protected phrases..." });
|
|
320
|
+
const phrases = protectedPhrases.map((s) => s.description);
|
|
321
|
+
devLog.debug("Applying protected phrases", { phrases }), updates.push(addProtectedPhrases(client, settingsId, phrases));
|
|
322
|
+
}
|
|
323
|
+
if (glossaryTerms.length > 0) {
|
|
324
|
+
onProgress?.({ stage: "applying", message: "Adding glossary terms..." });
|
|
325
|
+
const terms = glossaryTerms.map((s) => ({
|
|
326
|
+
// biome-ignore-start lint/style/noNonNullAssertion: safely handled
|
|
327
|
+
sourceValue: s.sourceValue,
|
|
328
|
+
targetValue: s.targetValue
|
|
329
|
+
// biome-ignore-end lint/style/noNonNullAssertion: safely handled
|
|
330
|
+
}));
|
|
331
|
+
devLog.debug("Applying glossary terms", { terms }), updates.push(addGlossaryTerms(client, terms, sourceLanguage, targetLanguage));
|
|
332
|
+
}
|
|
333
|
+
updates.length === 0 && devLog.warn("No valid updates to apply - all suggestions may have been empty"), await Promise.all(updates), devLog.info("All improvements applied successfully");
|
|
334
|
+
}
|
|
335
|
+
async function mergePromptWithAI(client, currentPrompt, newGuidance, context) {
|
|
336
|
+
const instruction = `
|
|
337
|
+
You are updating a ${context === "global" ? "translation" : `locale-specific translation for ${context}`} prompt.
|
|
338
|
+
Your task is to merge new guidance into an existing prompt.
|
|
339
|
+
|
|
340
|
+
CURRENT PROMPT:
|
|
341
|
+
${currentPrompt}
|
|
342
|
+
|
|
343
|
+
NEW GUIDANCE TO ADD:
|
|
344
|
+
${newGuidance}
|
|
345
|
+
|
|
346
|
+
INSTRUCTIONS:
|
|
347
|
+
- Read the current prompt carefully
|
|
348
|
+
- Integrate the new guidance into the prompt in a natural, coherent way
|
|
349
|
+
- If the new guidance conflicts with existing content, update or replace the conflicting parts
|
|
350
|
+
- If the new guidance is already covered, do not add redundant text
|
|
351
|
+
- Maintain the structure and tone of the prompt
|
|
352
|
+
- Output ONLY the updated prompt text, with no preamble or explanation
|
|
353
|
+
|
|
354
|
+
OUTPUT THE UPDATED PROMPT:`.trim(), mergedPrompt = await client.agent.action.prompt({
|
|
355
|
+
instruction,
|
|
356
|
+
format: "string"
|
|
357
|
+
});
|
|
358
|
+
return devLog.debug("AI merged prompt", {
|
|
359
|
+
context,
|
|
360
|
+
originalLength: currentPrompt.length,
|
|
361
|
+
newLength: mergedPrompt.length
|
|
362
|
+
}), mergedPrompt;
|
|
363
|
+
}
|
|
364
|
+
async function updateGlobalPrompt(client, settingsId, updates) {
|
|
365
|
+
devLog.info("Updating global prompt", { settingsId, updatesLength: updates.length });
|
|
366
|
+
const currentPrompt = await client.fetch("*[_id == $settingsId][0].globalPrompt", { settingsId });
|
|
367
|
+
devLog.debug("Fetched current global prompt", {
|
|
368
|
+
hasCurrentPrompt: !!currentPrompt,
|
|
369
|
+
currentLength: currentPrompt?.length
|
|
370
|
+
});
|
|
371
|
+
const newPrompt = currentPrompt ? await mergePromptWithAI(client, currentPrompt, updates, "global") : updates, result = await client.patch(settingsId).set({ globalPrompt: newPrompt.trim() }).commit();
|
|
372
|
+
devLog.info("Global prompt updated", { resultId: result._id, newPromptLength: newPrompt.length });
|
|
373
|
+
}
|
|
374
|
+
async function updateLocalePrompt(client, settingsId, locale, updates) {
|
|
375
|
+
devLog.info("Updating locale prompt", { settingsId, locale, updatesLength: updates.length });
|
|
376
|
+
const currentLocalePrompts = await client.fetch(
|
|
377
|
+
"*[_id == $settingsId][0].localePrompts",
|
|
378
|
+
{ settingsId }
|
|
379
|
+
);
|
|
380
|
+
devLog.debug("Fetched current locale prompts", { count: currentLocalePrompts?.length });
|
|
381
|
+
const existingPrompt = currentLocalePrompts?.find((lp) => lp.locale === locale);
|
|
382
|
+
if (devLog.debug("Found existing prompt for locale", { locale, hasExisting: !!existingPrompt }), existingPrompt?.prompt) {
|
|
383
|
+
const newPrompt = await mergePromptWithAI(client, existingPrompt.prompt, updates, locale), updatedPrompts = currentLocalePrompts.map(
|
|
384
|
+
(lp) => lp.locale === locale ? { ...lp, prompt: newPrompt.trim() } : lp
|
|
385
|
+
), result = await client.patch(settingsId).set({ localePrompts: updatedPrompts }).commit();
|
|
386
|
+
devLog.info("Locale prompt updated (merged)", { locale, resultId: result._id });
|
|
387
|
+
} else {
|
|
388
|
+
const result = await client.patch(settingsId).setIfMissing({ localePrompts: [] }).append("localePrompts", [
|
|
389
|
+
{
|
|
390
|
+
_type: "localePrompt",
|
|
391
|
+
_key: locale,
|
|
392
|
+
locale,
|
|
393
|
+
prompt: updates.trim()
|
|
394
|
+
}
|
|
395
|
+
]).commit();
|
|
396
|
+
devLog.info("Locale prompt created", { locale, resultId: result._id });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async function addProtectedPhrases(client, settingsId, phrases) {
|
|
400
|
+
devLog.info("Adding protected phrases", { settingsId, phrasesCount: phrases.length, phrases });
|
|
401
|
+
const currentPhrases = await client.fetch("*[_id == $settingsId][0].protectedPhrases", { settingsId });
|
|
402
|
+
devLog.debug("Fetched current protected phrases", { currentCount: currentPhrases?.length });
|
|
403
|
+
const newPhrases = phrases.filter((phrase) => !currentPhrases?.includes(phrase));
|
|
404
|
+
if (newPhrases.length === 0) {
|
|
405
|
+
devLog.info("No new protected phrases to add (all already exist)");
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const result = await client.patch(settingsId).setIfMissing({ protectedPhrases: [] }).append("protectedPhrases", newPhrases).commit();
|
|
409
|
+
devLog.info("Protected phrases added", { count: newPhrases.length, phrases: newPhrases, resultId: result._id });
|
|
410
|
+
}
|
|
411
|
+
async function addGlossaryTerms(client, terms, sourceLocale, targetLocale) {
|
|
412
|
+
const validTerms = terms.filter(({ sourceValue, targetValue }) => sourceValue === targetValue ? (devLog.warn("Skipping glossary term where source === target (should be a protected phrase)", {
|
|
413
|
+
sourceValue,
|
|
414
|
+
targetValue
|
|
415
|
+
}), !1) : !0);
|
|
416
|
+
if (validTerms.length === 0) {
|
|
417
|
+
devLog.info("No valid glossary terms to add");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
for (const { sourceValue, targetValue } of validTerms) {
|
|
421
|
+
const existingTerm = await client.fetch(
|
|
422
|
+
`*[_type == "translationGlossaryTerm"
|
|
423
|
+
&& count(translations[locale == $sourceLocale && value == $sourceValue]) > 0
|
|
424
|
+
][0] {
|
|
425
|
+
_id,
|
|
426
|
+
translations
|
|
427
|
+
}`,
|
|
428
|
+
{ sourceLocale, sourceValue }
|
|
429
|
+
);
|
|
430
|
+
existingTerm ? existingTerm.translations.some(
|
|
431
|
+
(t) => t.locale === targetLocale && t.value === targetValue
|
|
432
|
+
) ? devLog.info("Glossary term already has this translation", { sourceValue, targetValue, targetLocale }) : (await client.patch(existingTerm._id).append("translations", [
|
|
433
|
+
{
|
|
434
|
+
_type: "translation",
|
|
435
|
+
_key: targetLocale,
|
|
436
|
+
locale: targetLocale,
|
|
437
|
+
value: targetValue
|
|
438
|
+
}
|
|
439
|
+
]).commit(), devLog.info("Updated existing glossary term", { sourceValue, targetValue, targetLocale })) : (await client.create({
|
|
440
|
+
_type: "translationGlossaryTerm",
|
|
441
|
+
translations: [
|
|
442
|
+
{
|
|
443
|
+
_type: "translation",
|
|
444
|
+
_key: sourceLocale,
|
|
445
|
+
locale: sourceLocale,
|
|
446
|
+
value: sourceValue
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
_type: "translation",
|
|
450
|
+
_key: targetLocale,
|
|
451
|
+
locale: targetLocale,
|
|
452
|
+
value: targetValue
|
|
453
|
+
}
|
|
454
|
+
]
|
|
455
|
+
}), devLog.info("Created new glossary term", { sourceLocale, sourceValue, targetLocale, targetValue }));
|
|
456
|
+
}
|
|
457
|
+
devLog.info("Glossary terms processed", {
|
|
458
|
+
totalRequested: terms.length,
|
|
459
|
+
validAdded: validTerms.length,
|
|
460
|
+
skipped: terms.length - validTerms.length
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
const ICON_SIZE = 64, progressIconMap = {
|
|
464
|
+
in_progress: /* @__PURE__ */ jsx(Spinner, {}),
|
|
465
|
+
completed: /* @__PURE__ */ jsx(CheckmarkCircleIcon, { fontSize: ICON_SIZE }),
|
|
466
|
+
error: /* @__PURE__ */ jsx(ErrorOutlineIcon, { fontSize: ICON_SIZE })
|
|
467
|
+
}, ProgressPanel = ({
|
|
468
|
+
title,
|
|
469
|
+
description,
|
|
470
|
+
tone,
|
|
471
|
+
status = "in_progress",
|
|
472
|
+
icon,
|
|
473
|
+
...props
|
|
474
|
+
}) => /* @__PURE__ */ jsx(Card, { tone, padding: 4, ...props, children: /* @__PURE__ */ jsxs(Flex, { gap: 4, align: "center", justify: "center", children: [
|
|
475
|
+
icon || progressIconMap[status],
|
|
476
|
+
/* @__PURE__ */ jsxs(Stack, { space: 3, children: [
|
|
477
|
+
title && /* @__PURE__ */ jsx(Text, { size: 2, weight: "bold", children: title }),
|
|
478
|
+
description && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: description })
|
|
479
|
+
] })
|
|
480
|
+
] }) }), IMPROVEMENT_TYPE_LABELS = {
|
|
481
|
+
globalPrompt: "Global Prompt",
|
|
482
|
+
localePrompt: "Locale Prompt",
|
|
483
|
+
protectedPhrase: "Protected Phrase",
|
|
484
|
+
glossaryTerm: "Glossary Term"
|
|
485
|
+
}, createNewSuggestion = (type = "protectedPhrase") => {
|
|
486
|
+
const baseSuggestion = {
|
|
487
|
+
id: crypto.randomUUID(),
|
|
488
|
+
type,
|
|
489
|
+
description: "",
|
|
490
|
+
approved: !0
|
|
491
|
+
};
|
|
492
|
+
return type === "glossaryTerm" ? {
|
|
493
|
+
...baseSuggestion,
|
|
494
|
+
sourceValue: "",
|
|
495
|
+
targetValue: ""
|
|
496
|
+
} : baseSuggestion;
|
|
497
|
+
}, SuggestionReviewList = ({ suggestions, onSuggestionsChange }) => {
|
|
498
|
+
const [editingId, setEditingId] = useState(null), handleToggleApproval = (id) => {
|
|
499
|
+
const updated = suggestions.map((s) => s.id === id ? { ...s, approved: !s.approved } : s);
|
|
500
|
+
onSuggestionsChange(updated);
|
|
501
|
+
}, handleTypeChange = (id, newType) => {
|
|
502
|
+
const updated = suggestions.map((s) => {
|
|
503
|
+
if (s.id !== id)
|
|
504
|
+
return s;
|
|
505
|
+
if (newType === "glossaryTerm" && s.type !== "glossaryTerm") {
|
|
506
|
+
const match = s.description.match(/^(.+?)\s*→\s*(.+)$/);
|
|
507
|
+
return {
|
|
508
|
+
...s,
|
|
509
|
+
type: newType,
|
|
510
|
+
sourceValue: match ? match[1].trim() : "",
|
|
511
|
+
targetValue: match ? match[2].trim() : ""
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
return newType !== "glossaryTerm" && s.type === "glossaryTerm" ? {
|
|
515
|
+
...s,
|
|
516
|
+
type: newType,
|
|
517
|
+
description: s.sourceValue && s.targetValue ? `${s.sourceValue} \u2192 ${s.targetValue}` : s.description,
|
|
518
|
+
sourceValue: void 0,
|
|
519
|
+
targetValue: void 0
|
|
520
|
+
} : { ...s, type: newType };
|
|
521
|
+
});
|
|
522
|
+
onSuggestionsChange(updated);
|
|
523
|
+
}, handleDescriptionChange = (id, newDescription) => {
|
|
524
|
+
const updated = suggestions.map((s) => s.id === id ? { ...s, description: newDescription } : s);
|
|
525
|
+
onSuggestionsChange(updated);
|
|
526
|
+
}, handleGlossarySourceChange = (id, newSource) => {
|
|
527
|
+
const updated = suggestions.map(
|
|
528
|
+
(s) => s.id === id ? {
|
|
529
|
+
...s,
|
|
530
|
+
sourceValue: newSource,
|
|
531
|
+
description: `${newSource} \u2192 ${s.targetValue || ""}`
|
|
532
|
+
} : s
|
|
533
|
+
);
|
|
534
|
+
onSuggestionsChange(updated);
|
|
535
|
+
}, handleGlossaryTargetChange = (id, newTarget) => {
|
|
536
|
+
const updated = suggestions.map(
|
|
537
|
+
(s) => s.id === id ? {
|
|
538
|
+
...s,
|
|
539
|
+
targetValue: newTarget,
|
|
540
|
+
description: `${s.sourceValue || ""} \u2192 ${newTarget}`
|
|
541
|
+
} : s
|
|
542
|
+
);
|
|
543
|
+
onSuggestionsChange(updated);
|
|
544
|
+
}, handleRemove = (id) => {
|
|
545
|
+
const updated = suggestions.filter((s) => s.id !== id);
|
|
546
|
+
onSuggestionsChange(updated);
|
|
547
|
+
}, handleAddSuggestion = (type) => {
|
|
548
|
+
const newSuggestion = createNewSuggestion(type);
|
|
549
|
+
onSuggestionsChange([...suggestions, newSuggestion]), setEditingId(newSuggestion.id);
|
|
550
|
+
};
|
|
551
|
+
return suggestions.length === 0 ? /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
|
|
552
|
+
/* @__PURE__ */ jsx(
|
|
553
|
+
ProgressPanel,
|
|
554
|
+
{
|
|
555
|
+
status: "error",
|
|
556
|
+
tone: "transparent",
|
|
557
|
+
title: "No suggestions generated",
|
|
558
|
+
description: 'Try providing more specific feedback or enabling "Include Edits".',
|
|
559
|
+
icon: /* @__PURE__ */ jsx(EmptyIcon, { fontSize: 24 })
|
|
560
|
+
}
|
|
561
|
+
),
|
|
562
|
+
/* @__PURE__ */ jsx(Flex, { justify: "flex-start", children: /* @__PURE__ */ jsx(
|
|
563
|
+
Button,
|
|
564
|
+
{
|
|
565
|
+
mode: "ghost",
|
|
566
|
+
tone: "primary",
|
|
567
|
+
icon: AddIcon,
|
|
568
|
+
text: "Add Suggestion",
|
|
569
|
+
fontSize: 1,
|
|
570
|
+
onClick: () => handleAddSuggestion("protectedPhrase")
|
|
571
|
+
}
|
|
572
|
+
) })
|
|
573
|
+
] }) : /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
|
|
574
|
+
suggestions.map((suggestion) => {
|
|
575
|
+
const isEditing = editingId === suggestion.id, isGlossaryTerm = suggestion.type === "glossaryTerm", isPrompt = suggestion.type === "globalPrompt" || suggestion.type === "localePrompt";
|
|
576
|
+
return /* @__PURE__ */ jsx(
|
|
577
|
+
Card,
|
|
578
|
+
{
|
|
579
|
+
padding: 4,
|
|
580
|
+
border: !0,
|
|
581
|
+
tone: suggestion.approved ? "default" : "transparent",
|
|
582
|
+
style: { opacity: suggestion.approved ? 1 : 0.5 },
|
|
583
|
+
children: /* @__PURE__ */ jsx(Stack, { space: 3, children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 3, children: [
|
|
584
|
+
/* @__PURE__ */ jsx(Box, { paddingTop: 1, children: /* @__PURE__ */ jsx(Checkbox, { checked: suggestion.approved, onChange: () => handleToggleApproval(suggestion.id) }) }),
|
|
585
|
+
/* @__PURE__ */ jsxs(Stack, { space: 3, flex: 1, children: [
|
|
586
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", wrap: "wrap", children: [
|
|
587
|
+
/* @__PURE__ */ jsx(Box, { flex: 1, style: { minWidth: "200px" }, children: /* @__PURE__ */ jsx(
|
|
588
|
+
Select,
|
|
589
|
+
{
|
|
590
|
+
fontSize: 1,
|
|
591
|
+
value: suggestion.type,
|
|
592
|
+
onChange: (event) => handleTypeChange(suggestion.id, event.currentTarget.value),
|
|
593
|
+
children: Object.entries(IMPROVEMENT_TYPE_LABELS).map(([value, label]) => /* @__PURE__ */ jsx("option", { value, children: label }, value))
|
|
594
|
+
}
|
|
595
|
+
) }),
|
|
596
|
+
/* @__PURE__ */ jsx(
|
|
597
|
+
Button,
|
|
598
|
+
{
|
|
599
|
+
mode: "bleed",
|
|
600
|
+
tone: "critical",
|
|
601
|
+
icon: TrashIcon,
|
|
602
|
+
fontSize: 1,
|
|
603
|
+
padding: 2,
|
|
604
|
+
onClick: () => handleRemove(suggestion.id)
|
|
605
|
+
}
|
|
606
|
+
)
|
|
607
|
+
] }),
|
|
608
|
+
isGlossaryTerm ? /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
|
|
609
|
+
/* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
|
|
610
|
+
TextInput,
|
|
611
|
+
{
|
|
612
|
+
fontSize: 1,
|
|
613
|
+
placeholder: "Source term",
|
|
614
|
+
value: suggestion.sourceValue || "",
|
|
615
|
+
onChange: (event) => handleGlossarySourceChange(suggestion.id, event.currentTarget.value)
|
|
616
|
+
}
|
|
617
|
+
) }),
|
|
618
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "\u2192" }),
|
|
619
|
+
/* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
|
|
620
|
+
TextInput,
|
|
621
|
+
{
|
|
622
|
+
fontSize: 1,
|
|
623
|
+
placeholder: "Target term",
|
|
624
|
+
value: suggestion.targetValue || "",
|
|
625
|
+
onChange: (event) => handleGlossaryTargetChange(suggestion.id, event.currentTarget.value)
|
|
626
|
+
}
|
|
627
|
+
) })
|
|
628
|
+
] }) }) : isPrompt ? /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsx(
|
|
629
|
+
TextArea,
|
|
630
|
+
{
|
|
631
|
+
fontSize: 1,
|
|
632
|
+
rows: isEditing ? 6 : 3,
|
|
633
|
+
value: suggestion.description,
|
|
634
|
+
onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
|
|
635
|
+
onFocus: () => setEditingId(suggestion.id),
|
|
636
|
+
onBlur: () => setEditingId(null),
|
|
637
|
+
placeholder: "Enter prompt guidance..."
|
|
638
|
+
}
|
|
639
|
+
) }) : /* @__PURE__ */ jsx(
|
|
640
|
+
TextInput,
|
|
641
|
+
{
|
|
642
|
+
fontSize: 1,
|
|
643
|
+
value: suggestion.description,
|
|
644
|
+
onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
|
|
645
|
+
placeholder: "Enter protected phrase..."
|
|
646
|
+
}
|
|
647
|
+
)
|
|
648
|
+
] })
|
|
649
|
+
] }) })
|
|
650
|
+
},
|
|
651
|
+
suggestion.id
|
|
652
|
+
);
|
|
653
|
+
}),
|
|
654
|
+
/* @__PURE__ */ jsx(Flex, { justify: "flex-start", children: /* @__PURE__ */ jsx(
|
|
655
|
+
Button,
|
|
656
|
+
{
|
|
657
|
+
mode: "ghost",
|
|
658
|
+
tone: "neutral",
|
|
659
|
+
icon: AddIcon,
|
|
660
|
+
text: "Add Suggestion",
|
|
661
|
+
fontSize: 1,
|
|
662
|
+
onClick: () => handleAddSuggestion("protectedPhrase")
|
|
663
|
+
}
|
|
664
|
+
) })
|
|
665
|
+
] });
|
|
666
|
+
}, GiveFeedbackDialog = ({ setIsDialogOpen }) => {
|
|
667
|
+
const client = useClient({ apiVersion: "vX" }), { document, config, translationMetadata } = useTranslationContext(), { loading } = useTranslationMetadata({
|
|
668
|
+
documentId: document._id
|
|
669
|
+
}), [feedback, setFeedback] = useState(""), [shouldIncludeEdits, setShouldIncludeEdits] = useState(!1), [processingState, setProcessingState] = useState("idle"), [suggestions, setSuggestions] = useState([]), [changeSummary, setChangeSummary] = useState(""), [error, setError] = useState(""), handleShouldIncludeEditsChange = () => {
|
|
670
|
+
setShouldIncludeEdits(!shouldIncludeEdits);
|
|
671
|
+
}, handleCancel = () => {
|
|
672
|
+
setIsDialogOpen(!1);
|
|
673
|
+
}, handleAnalyze = async () => {
|
|
674
|
+
if (!feedback && !shouldIncludeEdits) {
|
|
675
|
+
setError('Please provide feedback or enable "Include Edits"');
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (!config) {
|
|
679
|
+
setError("Missing plugin configuration");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
setError(""), setProcessingState("analyzing"), setChangeSummary("");
|
|
683
|
+
try {
|
|
684
|
+
const translationHistory2 = translationMetadata?.translationHistory?.find(
|
|
685
|
+
(history) => history.language === document[config.languageFieldName]
|
|
686
|
+
);
|
|
687
|
+
if (!translationHistory2)
|
|
688
|
+
throw new Error(
|
|
689
|
+
"No translation history found for this document. Please ensure this document was translated using the AI Translation plugin."
|
|
690
|
+
);
|
|
691
|
+
const { sourceSnapshot, targetSnapshot, sourceLanguage } = translationHistory2;
|
|
692
|
+
if (!sourceSnapshot || !targetSnapshot)
|
|
693
|
+
throw new Error("Translation snapshots are missing from history.");
|
|
694
|
+
const generatedSuggestions = await analyzeFeedback({
|
|
695
|
+
client,
|
|
696
|
+
schemaId: config.schemaId,
|
|
697
|
+
sourceLanguage: sourceLanguage || "en-us",
|
|
698
|
+
targetLanguage: document[config.languageFieldName],
|
|
699
|
+
userFeedback: feedback || void 0,
|
|
700
|
+
shouldIncludeEdits,
|
|
701
|
+
sourceSnapshot,
|
|
702
|
+
targetSnapshot,
|
|
703
|
+
currentDocument: shouldIncludeEdits ? document : void 0,
|
|
704
|
+
onProgress: ({ stage, message }) => {
|
|
705
|
+
setProcessingState(stage), setChangeSummary(message);
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
setSuggestions(generatedSuggestions), setProcessingState("reviewing");
|
|
709
|
+
} catch (err) {
|
|
710
|
+
console.error("Error analyzing feedback:", err), setError(err instanceof Error ? err.message : "An unexpected error occurred"), setProcessingState("error");
|
|
711
|
+
}
|
|
712
|
+
}, handleApplySuggestions = async () => {
|
|
713
|
+
if (!config) {
|
|
714
|
+
setError("Missing plugin configuration");
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (suggestions.filter((s) => s.approved).length === 0) {
|
|
718
|
+
setError("Please approve at least one suggestion to apply");
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
setError(""), setProcessingState("applying");
|
|
722
|
+
try {
|
|
723
|
+
const translationHistory2 = translationMetadata?.translationHistory?.find(
|
|
724
|
+
(history) => history.language === document[config.languageFieldName]
|
|
725
|
+
);
|
|
726
|
+
if (!translationHistory2)
|
|
727
|
+
throw new Error("Translation history not found");
|
|
728
|
+
await applySuggestions({
|
|
729
|
+
client,
|
|
730
|
+
schemaId: config.schemaId,
|
|
731
|
+
sourceLanguage: translationHistory2.sourceLanguage || "en-us",
|
|
732
|
+
targetLanguage: document[config.languageFieldName],
|
|
733
|
+
suggestions,
|
|
734
|
+
onProgress: ({ stage, message }) => {
|
|
735
|
+
setProcessingState(stage), setChangeSummary(message);
|
|
736
|
+
}
|
|
737
|
+
}), setProcessingState("completed");
|
|
738
|
+
} catch (err) {
|
|
739
|
+
console.error("Error applying suggestions:", err), setError(err instanceof Error ? err.message : "An unexpected error occurred"), setProcessingState("error");
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
if (loading)
|
|
743
|
+
return /* @__PURE__ */ jsx(ProgressPanel, { title: "Loading translation metadata" });
|
|
744
|
+
if (processingState === "analyzing")
|
|
745
|
+
return /* @__PURE__ */ jsx(ProgressPanel, { title: "Analyzing feedback with AI...", description: changeSummary });
|
|
746
|
+
if (processingState === "reviewing")
|
|
747
|
+
return /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
|
|
748
|
+
/* @__PURE__ */ jsxs(Stack, { space: 4, children: [
|
|
749
|
+
/* @__PURE__ */ jsx(Heading, { children: "Review Suggestions" }),
|
|
750
|
+
/* @__PURE__ */ jsx(Text, { muted: !0, children: "The AI has analyzed your feedback and generated the following suggestions. Review and edit them before applying." })
|
|
751
|
+
] }),
|
|
752
|
+
/* @__PURE__ */ jsx(SuggestionReviewList, { suggestions, onSuggestionsChange: setSuggestions }),
|
|
753
|
+
error && /* @__PURE__ */ jsx(Card, { padding: 3, tone: "critical", border: !0, children: /* @__PURE__ */ jsx(Text, { size: 1, children: error }) }),
|
|
754
|
+
/* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, paddingTop: 2, children: [
|
|
755
|
+
/* @__PURE__ */ jsx(Flex, { gap: 2, children: /* @__PURE__ */ jsx(
|
|
756
|
+
Button,
|
|
757
|
+
{
|
|
758
|
+
type: "button",
|
|
759
|
+
mode: "bleed",
|
|
760
|
+
tone: "neutral",
|
|
761
|
+
onClick: () => setProcessingState("idle"),
|
|
762
|
+
icon: /* @__PURE__ */ jsx(ArrowLeftIcon, {}),
|
|
763
|
+
text: "Back"
|
|
764
|
+
}
|
|
765
|
+
) }),
|
|
766
|
+
/* @__PURE__ */ jsxs(
|
|
767
|
+
Button,
|
|
768
|
+
{
|
|
769
|
+
type: "button",
|
|
770
|
+
mode: "ghost",
|
|
771
|
+
tone: "positive",
|
|
772
|
+
size: 1,
|
|
773
|
+
onClick: handleApplySuggestions,
|
|
774
|
+
disabled: suggestions.filter((s) => s.approved).length === 0,
|
|
775
|
+
children: [
|
|
776
|
+
"Apply ",
|
|
777
|
+
suggestions.filter((s) => s.approved).length,
|
|
778
|
+
" Suggestion",
|
|
779
|
+
suggestions.filter((s) => s.approved).length !== 1 ? "s" : ""
|
|
780
|
+
]
|
|
781
|
+
}
|
|
782
|
+
)
|
|
783
|
+
] })
|
|
784
|
+
] });
|
|
785
|
+
if (processingState === "applying")
|
|
786
|
+
return /* @__PURE__ */ jsx(ProgressPanel, { title: "Updating translation settings...", description: changeSummary, icon: /* @__PURE__ */ jsx(EditIcon, {}) });
|
|
787
|
+
if (processingState === "completed") {
|
|
788
|
+
const appliedCount = suggestions.filter((s) => s.approved).length, description = `${appliedCount} suggestion${appliedCount !== 1 ? "s" : ""} applied. Translation prompts and glossary havebeen updated based on your feedback.`;
|
|
789
|
+
return /* @__PURE__ */ jsxs(Box, { padding: 4, children: [
|
|
790
|
+
/* @__PURE__ */ jsx(ProgressPanel, { title: "Translation settings updated", description, status: "completed" }),
|
|
791
|
+
/* @__PURE__ */ jsx(Flex, { justify: "flex-end", paddingTop: 3, children: /* @__PURE__ */ jsx(Button, { type: "button", mode: "ghost", tone: "primary", size: 1, onClick: handleCancel, children: "Close" }) })
|
|
792
|
+
] });
|
|
793
|
+
}
|
|
794
|
+
return processingState === "error" ? /* @__PURE__ */ jsx(Box, { padding: 4, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
|
|
795
|
+
/* @__PURE__ */ jsx(ProgressPanel, { status: "error", title: "Error processing feedback", description: error, tone: "critical" }),
|
|
796
|
+
/* @__PURE__ */ jsxs(Flex, { justify: "flex-end", gap: 2, children: [
|
|
797
|
+
/* @__PURE__ */ jsx(Button, { type: "button", mode: "ghost", onClick: handleCancel, children: "Close" }),
|
|
798
|
+
/* @__PURE__ */ jsx(Button, { type: "reset", mode: "ghost", tone: "primary", onClick: () => setProcessingState("idle"), children: "Reset" })
|
|
799
|
+
] })
|
|
800
|
+
] }) }) : /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
|
|
801
|
+
/* @__PURE__ */ jsx(Card, { tone: "transparent", padding: 4, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
|
|
802
|
+
/* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
|
|
803
|
+
/* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
804
|
+
/* @__PURE__ */ jsx(Text, { size: 2, muted: !0, children: "Enter your feedback for the translation AI agent." }),
|
|
805
|
+
/* @__PURE__ */ jsx(
|
|
806
|
+
Tooltip,
|
|
807
|
+
{
|
|
808
|
+
portal: !0,
|
|
809
|
+
content: /* @__PURE__ */ jsx(Box, { padding: 2, style: { maxWidth: "280px" }, children: /* @__PURE__ */ jsx(Text, { size: 1, children: "The AI agent will use your feedback to improve translation prompts, update protected phrases, and modify the glossary. Be specific about what should be changed." }) }),
|
|
810
|
+
children: /* @__PURE__ */ jsx(Text, { size: 1, children: /* @__PURE__ */ jsx(InfoOutlineIcon, {}) })
|
|
811
|
+
}
|
|
812
|
+
)
|
|
813
|
+
] }),
|
|
814
|
+
/* @__PURE__ */ jsx(
|
|
815
|
+
TextArea,
|
|
816
|
+
{
|
|
817
|
+
rows: 8,
|
|
818
|
+
fontSize: 2,
|
|
819
|
+
onChange: (event) => setFeedback(event.currentTarget.value),
|
|
820
|
+
padding: 3,
|
|
821
|
+
placeholder: 'Example: "Braze" is a branded term and should not be translated.',
|
|
822
|
+
value: feedback
|
|
823
|
+
}
|
|
824
|
+
)
|
|
825
|
+
] }) }),
|
|
826
|
+
/* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsx(Stack, { space: 3, children: /* @__PURE__ */ jsx(
|
|
827
|
+
Button,
|
|
828
|
+
{
|
|
829
|
+
type: "button",
|
|
830
|
+
mode: shouldIncludeEdits ? "ghost" : "bleed",
|
|
831
|
+
size: 2,
|
|
832
|
+
onClick: handleShouldIncludeEditsChange,
|
|
833
|
+
tone: shouldIncludeEdits ? "primary" : "default",
|
|
834
|
+
children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 3, children: [
|
|
835
|
+
/* @__PURE__ */ jsx(Box, { paddingTop: 1, children: /* @__PURE__ */ jsx(Checkbox, { checked: shouldIncludeEdits, readOnly: !0, style: { pointerEvents: "none" } }) }),
|
|
836
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, flex: 1, children: [
|
|
837
|
+
/* @__PURE__ */ jsx(Text, { size: 2, weight: "medium", children: "Include Edits in Feedback" }),
|
|
838
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, style: { textAlign: "left" }, children: "The AI will analyze edits you made to the translation." })
|
|
839
|
+
] })
|
|
840
|
+
] })
|
|
841
|
+
}
|
|
842
|
+
) }) })
|
|
843
|
+
] }) }),
|
|
844
|
+
/* @__PURE__ */ jsxs(Flex, { justify: "flex-end", gap: 2, paddingTop: 2, children: [
|
|
845
|
+
/* @__PURE__ */ jsx(Button, { type: "button", mode: "ghost", size: 1, onClick: handleCancel, children: "Cancel" }),
|
|
846
|
+
/* @__PURE__ */ jsx(
|
|
847
|
+
Button,
|
|
848
|
+
{
|
|
849
|
+
type: "submit",
|
|
850
|
+
mode: "ghost",
|
|
851
|
+
tone: "positive",
|
|
852
|
+
size: 1,
|
|
853
|
+
onClick: handleAnalyze,
|
|
854
|
+
disabled: !feedback.trim() && !shouldIncludeEdits,
|
|
855
|
+
children: "Analyze Feedback"
|
|
856
|
+
}
|
|
857
|
+
)
|
|
858
|
+
] })
|
|
859
|
+
] });
|
|
860
|
+
}, translationReducer = (context, action) => {
|
|
861
|
+
const logContext = {
|
|
862
|
+
documentId: context.document?._id,
|
|
863
|
+
actionType: action.type,
|
|
864
|
+
component: "translationReducer"
|
|
865
|
+
};
|
|
866
|
+
switch (devLog.debug("Translation action dispatched", {
|
|
867
|
+
...logContext,
|
|
868
|
+
payload: action.payload
|
|
869
|
+
}), action.type) {
|
|
870
|
+
case "SET_OVERALL_STATUS":
|
|
871
|
+
return { ...context, overallStatus: action.payload };
|
|
872
|
+
case "SET_TRANSLATION_METADATA":
|
|
873
|
+
return { ...context, translationMetadata: action.payload };
|
|
874
|
+
case "SET_SELECTED_TARGET_LANGUAGES":
|
|
875
|
+
return {
|
|
876
|
+
...context,
|
|
877
|
+
selectedTargetLanguages: action.payload
|
|
878
|
+
};
|
|
879
|
+
case "SET_SHOULD_TRANSLATE_DIFFS":
|
|
880
|
+
return {
|
|
881
|
+
...context,
|
|
882
|
+
shouldTranslateDiffs: action.payload
|
|
883
|
+
};
|
|
884
|
+
case "SET_SHOULD_TRANSLATE_REFERENCES":
|
|
885
|
+
return {
|
|
886
|
+
...context,
|
|
887
|
+
shouldTranslateReferences: action.payload
|
|
888
|
+
};
|
|
889
|
+
case "SET_MAX_REFERENCE_DEPTH":
|
|
890
|
+
return {
|
|
891
|
+
...context,
|
|
892
|
+
maxReferenceDepth: action.payload
|
|
893
|
+
};
|
|
894
|
+
case "SET_SHOULD_OVERWRITE_FRESH_TRANSLATIONS":
|
|
895
|
+
return {
|
|
896
|
+
...context,
|
|
897
|
+
shouldOverwriteFreshTranslations: action.payload
|
|
898
|
+
};
|
|
899
|
+
case "SET_REFERENCES":
|
|
900
|
+
return {
|
|
901
|
+
...context,
|
|
902
|
+
references: action.payload
|
|
903
|
+
};
|
|
904
|
+
case "SET_TRANSLATION_JOB_STATUS": {
|
|
905
|
+
const jobKey = `${action.payload._id}_${action.payload.language}`, newTranslationJobs = new Map(context.translationJobs);
|
|
906
|
+
return newTranslationJobs.set(jobKey, defaults(action.payload, newTranslationJobs.get(jobKey))), {
|
|
907
|
+
...context,
|
|
908
|
+
translationJobs: newTranslationJobs
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
default:
|
|
912
|
+
return context;
|
|
913
|
+
}
|
|
914
|
+
}, TranslationContextProvider = ({ document, config, children }) => {
|
|
915
|
+
devLog.debug("TranslationContextProvider initialized", {
|
|
916
|
+
documentId: document._id,
|
|
917
|
+
documentType: document._type,
|
|
918
|
+
supportedLanguages: config.supportedLanguages?.map((l) => l.id),
|
|
919
|
+
component: "TranslationContextProvider"
|
|
920
|
+
});
|
|
921
|
+
const initialContext = {
|
|
922
|
+
document,
|
|
923
|
+
config,
|
|
924
|
+
overallStatus: "idle",
|
|
925
|
+
translationMetadata: null,
|
|
926
|
+
selectedTargetLanguages: [],
|
|
927
|
+
shouldTranslateDiffs: !1,
|
|
928
|
+
shouldOverwriteFreshTranslations: !1,
|
|
929
|
+
shouldTranslateReferences: !1,
|
|
930
|
+
maxReferenceDepth: 1,
|
|
931
|
+
references: [],
|
|
932
|
+
translationJobs: /* @__PURE__ */ new Map()
|
|
933
|
+
}, [context, dispatch] = useReducer(translationReducer, initialContext);
|
|
934
|
+
return devLog.debug("Translation context state initialized", {
|
|
935
|
+
documentId: document._id,
|
|
936
|
+
overallStatus: context.overallStatus,
|
|
937
|
+
selectedLanguages: context.selectedTargetLanguages,
|
|
938
|
+
shouldTranslateDiffs: context.shouldTranslateDiffs,
|
|
939
|
+
component: "TranslationContextProvider"
|
|
940
|
+
}), /* @__PURE__ */ jsx(TranslationContext.Provider, { value: { ...context, dispatch }, children });
|
|
941
|
+
}, getGiveTranslationFeedbackAction = (config) => ({ draft, published }) => {
|
|
942
|
+
const document = draft ?? published, [isDialogOpen, setIsDialogOpen] = useState(!1);
|
|
943
|
+
return devLog.debug("ImproveTranslationPromptsAction initialized", {
|
|
944
|
+
documentId: document?._id,
|
|
945
|
+
documentType: document?._type
|
|
946
|
+
}), !document || !config.translatableSchemaTypes?.includes(document._type) ? (devLog.warn("Document not in translatable schema types", {
|
|
947
|
+
documentType: document?._type,
|
|
948
|
+
translatableTypes: config.translatableSchemaTypes
|
|
949
|
+
}), null) : config ? {
|
|
950
|
+
label: "Give Translation Feedback",
|
|
951
|
+
icon: TranslateIcon,
|
|
952
|
+
onHandle: () => {
|
|
953
|
+
devLog.info("Give Translation Feedback dialog opened", {
|
|
954
|
+
documentId: document._id,
|
|
955
|
+
documentType: document._type,
|
|
956
|
+
component: "GiveTranslationFeedbackAction"
|
|
957
|
+
}), setIsDialogOpen(!0);
|
|
958
|
+
},
|
|
959
|
+
dialog: isDialogOpen && {
|
|
960
|
+
type: "dialog",
|
|
961
|
+
onClose: () => setIsDialogOpen(!1),
|
|
962
|
+
header: "Give Translation Feedback",
|
|
963
|
+
content: /* @__PURE__ */ jsx(TranslationContextProvider, { document, config, children: /* @__PURE__ */ jsx(GiveFeedbackDialog, { setIsDialogOpen }) })
|
|
964
|
+
}
|
|
965
|
+
} : (devLog.error("Missing config in TranslateAction"), null);
|
|
966
|
+
}, translationGlossaryTerm = defineType({
|
|
967
|
+
name: "translationGlossaryTerm",
|
|
968
|
+
title: "Translation Glossary Term",
|
|
969
|
+
icon: TranslateIcon,
|
|
970
|
+
type: "document",
|
|
971
|
+
fields: [
|
|
972
|
+
defineField({
|
|
973
|
+
name: "translations",
|
|
974
|
+
title: "Translations",
|
|
975
|
+
type: "array",
|
|
976
|
+
of: [
|
|
977
|
+
{
|
|
978
|
+
name: "translation",
|
|
979
|
+
title: "Translation",
|
|
980
|
+
type: "object",
|
|
981
|
+
fields: [
|
|
982
|
+
defineField({
|
|
983
|
+
name: "locale",
|
|
984
|
+
type: "string"
|
|
985
|
+
}),
|
|
986
|
+
defineField({
|
|
987
|
+
name: "value",
|
|
988
|
+
type: "string"
|
|
989
|
+
})
|
|
990
|
+
]
|
|
991
|
+
}
|
|
992
|
+
]
|
|
993
|
+
})
|
|
994
|
+
],
|
|
995
|
+
preview: {
|
|
996
|
+
select: {
|
|
997
|
+
translations: "translations"
|
|
998
|
+
},
|
|
999
|
+
prepare({ translations }) {
|
|
1000
|
+
const enTranslation = translations?.find((t) => t.locale === "en-us"), firstTranslation = translations?.[0];
|
|
1001
|
+
return {
|
|
1002
|
+
title: enTranslation?.value || firstTranslation?.value || "No translation available"
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}), translationHistory = defineField({
|
|
1007
|
+
name: "translationHistory",
|
|
1008
|
+
type: "array",
|
|
1009
|
+
of: [
|
|
1010
|
+
{
|
|
1011
|
+
name: "targetLanguageHistory",
|
|
1012
|
+
type: "object",
|
|
1013
|
+
fields: [
|
|
1014
|
+
defineField({
|
|
1015
|
+
name: "language",
|
|
1016
|
+
title: "Language",
|
|
1017
|
+
description: "The language of the target document",
|
|
1018
|
+
type: "string"
|
|
1019
|
+
}),
|
|
1020
|
+
defineField({
|
|
1021
|
+
name: "sourceId",
|
|
1022
|
+
title: "Source ID",
|
|
1023
|
+
description: "The ID of the source document",
|
|
1024
|
+
type: "string"
|
|
1025
|
+
}),
|
|
1026
|
+
defineField({
|
|
1027
|
+
name: "sourceRev",
|
|
1028
|
+
title: "Source Revision",
|
|
1029
|
+
description: "The revision ID of the source document at the time of translation",
|
|
1030
|
+
type: "string"
|
|
1031
|
+
}),
|
|
1032
|
+
defineField({
|
|
1033
|
+
name: "sourceLanguage",
|
|
1034
|
+
title: "Source Language",
|
|
1035
|
+
description: "The language of the source document",
|
|
1036
|
+
type: "string"
|
|
1037
|
+
}),
|
|
1038
|
+
defineField({
|
|
1039
|
+
name: "sourceSnapshot",
|
|
1040
|
+
title: "Source Snapshot",
|
|
1041
|
+
description: "The snapshot of the source document at the time of translation",
|
|
1042
|
+
type: "text"
|
|
1043
|
+
}),
|
|
1044
|
+
defineField({
|
|
1045
|
+
name: "targetId",
|
|
1046
|
+
title: "Target ID",
|
|
1047
|
+
description: "The ID of the target document",
|
|
1048
|
+
type: "string"
|
|
1049
|
+
})
|
|
1050
|
+
]
|
|
1051
|
+
}
|
|
1052
|
+
]
|
|
1053
|
+
}), localePrompt = defineField({
|
|
1054
|
+
name: "localePrompt",
|
|
1055
|
+
title: "Locale Prompt",
|
|
1056
|
+
type: "object",
|
|
1057
|
+
fields: [
|
|
1058
|
+
defineField({
|
|
1059
|
+
name: "locale",
|
|
1060
|
+
type: "string"
|
|
1061
|
+
}),
|
|
1062
|
+
defineField({
|
|
1063
|
+
name: "prompt",
|
|
1064
|
+
type: "text",
|
|
1065
|
+
rows: 5
|
|
1066
|
+
})
|
|
1067
|
+
]
|
|
1068
|
+
}), translationSettings = defineType({
|
|
1069
|
+
name: "translationSettings",
|
|
1070
|
+
title: "Translation Settings",
|
|
1071
|
+
icon: TranslateIcon,
|
|
1072
|
+
type: "document",
|
|
1073
|
+
fields: [
|
|
1074
|
+
defineField({
|
|
1075
|
+
name: "globalPrompt",
|
|
1076
|
+
title: "Global Translation Prompt",
|
|
1077
|
+
description: "A prompt that will given to the Sanity AI Agent for all translation tasks.",
|
|
1078
|
+
type: "text",
|
|
1079
|
+
rows: 5
|
|
1080
|
+
}),
|
|
1081
|
+
defineField({
|
|
1082
|
+
name: "localePrompts",
|
|
1083
|
+
title: "Locale Prompts",
|
|
1084
|
+
description: "Prompts that will be given to the Sanity AI Agent for each locale.",
|
|
1085
|
+
type: "array",
|
|
1086
|
+
of: [localePrompt]
|
|
1087
|
+
}),
|
|
1088
|
+
defineField({
|
|
1089
|
+
name: "protectedPhrases",
|
|
1090
|
+
title: "Protected Phrases",
|
|
1091
|
+
description: "Phrases that will never be translated.",
|
|
1092
|
+
type: "array",
|
|
1093
|
+
of: [{ type: "string" }]
|
|
1094
|
+
})
|
|
1095
|
+
],
|
|
1096
|
+
preview: {
|
|
1097
|
+
prepare: () => ({
|
|
1098
|
+
title: "Translation Settings"
|
|
1099
|
+
})
|
|
1100
|
+
}
|
|
1101
|
+
}), fetchTranslationPrompts = async ({
|
|
1102
|
+
client,
|
|
1103
|
+
sourceLanguage,
|
|
1104
|
+
targetLanguages
|
|
1105
|
+
}) => {
|
|
1106
|
+
const translationSettings2 = await client.fetch(
|
|
1107
|
+
`*[_id == "translation-settings"][0] {
|
|
1108
|
+
globalPrompt,
|
|
1109
|
+
localePrompts[locale in $targetLanguages],
|
|
1110
|
+
protectedPhrases
|
|
1111
|
+
}`,
|
|
1112
|
+
{ targetLanguages }
|
|
1113
|
+
), translationGlossaryTerms = await client.fetch(
|
|
1114
|
+
`*[
|
|
1115
|
+
_type == "translationGlossaryTerm"
|
|
1116
|
+
&& count(translations[locale in $targetLanguages]) > 0
|
|
1117
|
+
&& count(translations[locale == $sourceLanguage]) > 0
|
|
1118
|
+
].translations`,
|
|
1119
|
+
{ targetLanguages, sourceLanguage }
|
|
1120
|
+
) || [], glossaryTermsByLocale = {};
|
|
1121
|
+
for (const targetLanguage of targetLanguages) {
|
|
1122
|
+
const relevantTranslations = translationGlossaryTerms.filter(
|
|
1123
|
+
(termTranslations) => termTranslations.some((translation) => translation.locale === targetLanguage)
|
|
1124
|
+
).map((termTranslations) => {
|
|
1125
|
+
const from = termTranslations.find((translation) => translation.locale === sourceLanguage)?.value, to = termTranslations.find((translation) => translation.locale === targetLanguage)?.value;
|
|
1126
|
+
return !from || !to ? null : { from, to };
|
|
1127
|
+
}).filter((item) => item !== null);
|
|
1128
|
+
glossaryTermsByLocale[targetLanguage] = relevantTranslations;
|
|
1129
|
+
}
|
|
1130
|
+
return {
|
|
1131
|
+
globalPrompt: translationSettings2?.globalPrompt,
|
|
1132
|
+
protectedPhrases: translationSettings2?.protectedPhrases,
|
|
1133
|
+
localeInstructions: Object.fromEntries(
|
|
1134
|
+
targetLanguages.map((locale) => [
|
|
1135
|
+
locale,
|
|
1136
|
+
{
|
|
1137
|
+
prompt: translationSettings2?.localePrompts?.find((lp) => lp.locale === locale)?.prompt,
|
|
1138
|
+
glossaryTerms: formatGlossaryTermInstruction(glossaryTermsByLocale[locale])
|
|
1139
|
+
}
|
|
1140
|
+
])
|
|
1141
|
+
)
|
|
1142
|
+
};
|
|
1143
|
+
}, formatGlossaryTermInstruction = (terms) => {
|
|
1144
|
+
const instructions = ["Translate the following terms exactly as described below:"];
|
|
1145
|
+
return terms.forEach(({ from, to }) => {
|
|
1146
|
+
instructions.push(`"${from}" -> "${to}"`);
|
|
1147
|
+
}), instructions.join(`
|
|
1148
|
+
`);
|
|
1149
|
+
}, findKeyValues = (obj, targetKey) => {
|
|
1150
|
+
const results = [], recursiveSearch = (current, currentPath) => {
|
|
1151
|
+
if (current != null) {
|
|
1152
|
+
if (Array.isArray(current)) {
|
|
1153
|
+
current.forEach((item, index) => {
|
|
1154
|
+
recursiveSearch(item, [...currentPath, index]);
|
|
1155
|
+
});
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
if (typeof current == "object")
|
|
1159
|
+
for (const [key, value] of Object.entries(current)) {
|
|
1160
|
+
const newPath = [...currentPath, key];
|
|
1161
|
+
key === targetKey && results.push({
|
|
1162
|
+
value,
|
|
1163
|
+
path: newPath
|
|
1164
|
+
}), recursiveSearch(value, newPath);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
return recursiveSearch(obj, []), results;
|
|
1169
|
+
}, localizeReferences = async ({ client, documentId, languageFieldName }) => {
|
|
1170
|
+
try {
|
|
1171
|
+
const doc = await client.fetch("*[_id == $documentId][0]", { documentId });
|
|
1172
|
+
if (!doc) {
|
|
1173
|
+
console.error(documentId, "not found");
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
const targetLanguage = doc[languageFieldName];
|
|
1177
|
+
if (!targetLanguage) {
|
|
1178
|
+
console.error(documentId, "no value found for language field:", languageFieldName);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
const refs = findKeyValues(doc, "_ref");
|
|
1182
|
+
if (!refs?.length)
|
|
1183
|
+
return;
|
|
1184
|
+
await updateReferences(documentId, client, refs, targetLanguage);
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
console.error("Error localizing references:", error);
|
|
1187
|
+
}
|
|
1188
|
+
}, updateReferences = async (documentId, client, refs, targetLanguage) => {
|
|
1189
|
+
const patch = client.patch(documentId);
|
|
1190
|
+
if (!refs?.length)
|
|
1191
|
+
return [];
|
|
1192
|
+
for (const ref of refs) {
|
|
1193
|
+
const newId = await client.fetch(
|
|
1194
|
+
"(*[_type == $translationMetadataSchemaType && references($sourceId)].translations[_key == $targetLanguage].value._ref)[0]",
|
|
1195
|
+
{
|
|
1196
|
+
translationMetadataSchemaType: TRANSLATION_METADATA_TYPE,
|
|
1197
|
+
sourceId: undraftId(ref.value),
|
|
1198
|
+
targetLanguage
|
|
1199
|
+
}
|
|
1200
|
+
);
|
|
1201
|
+
newId && (patch.set({ [pathToString(ref.path)]: undraftId(newId) }), patch.set({ [`${pathToString(ref.path.slice(0, -1))}._weak`]: !0 }), patch.set({
|
|
1202
|
+
[`${pathToString(ref.path.slice(0, -1))}._strengthenOnPublish`]: {
|
|
1203
|
+
_type: "reference",
|
|
1204
|
+
_weak: !1
|
|
1205
|
+
}
|
|
1206
|
+
}), await patch.commit());
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
class Semaphore {
|
|
1210
|
+
permits;
|
|
1211
|
+
queue = [];
|
|
1212
|
+
constructor(permits) {
|
|
1213
|
+
this.permits = permits;
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Acquire a permit from the semaphore
|
|
1217
|
+
* If no permits are available, the caller will wait until one becomes available
|
|
1218
|
+
*/
|
|
1219
|
+
async acquire() {
|
|
1220
|
+
return this.permits > 0 ? (this.permits--, Promise.resolve()) : new Promise((resolve) => {
|
|
1221
|
+
this.queue.push(resolve);
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Release a permit back to the semaphore
|
|
1226
|
+
* If there are waiting callers, one will be woken up
|
|
1227
|
+
*/
|
|
1228
|
+
release() {
|
|
1229
|
+
const next = this.queue.shift();
|
|
1230
|
+
next ? next() : this.permits++;
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Execute a function with semaphore protection
|
|
1234
|
+
* Automatically acquires and releases the permit
|
|
1235
|
+
*/
|
|
1236
|
+
async withPermit(fn) {
|
|
1237
|
+
await this.acquire();
|
|
1238
|
+
try {
|
|
1239
|
+
return await fn();
|
|
1240
|
+
} finally {
|
|
1241
|
+
this.release();
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Get the number of available permits
|
|
1246
|
+
*/
|
|
1247
|
+
availablePermits() {
|
|
1248
|
+
return this.permits;
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Get the number of waiting callers
|
|
1252
|
+
*/
|
|
1253
|
+
queueLength() {
|
|
1254
|
+
return this.queue.length;
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Update the number of permits
|
|
1258
|
+
* If increasing permits, waiting callers will be notified
|
|
1259
|
+
* If decreasing permits below current available, available permits will be reduced
|
|
1260
|
+
*/
|
|
1261
|
+
updatePermits(newPermits) {
|
|
1262
|
+
const currentTotal = this.permits + this.queue.length, difference = newPermits - currentTotal;
|
|
1263
|
+
if (difference > 0) {
|
|
1264
|
+
for (let i = 0; i < difference && this.queue.length > 0; i++) {
|
|
1265
|
+
const next = this.queue.shift();
|
|
1266
|
+
next && next();
|
|
1267
|
+
}
|
|
1268
|
+
const remainingIncrease = difference - (currentTotal - this.permits - this.queue.length);
|
|
1269
|
+
remainingIncrease > 0 && (this.permits += remainingIncrease);
|
|
1270
|
+
} else difference < 0 && (this.permits = Math.max(0, this.permits + difference));
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages) => {
|
|
1274
|
+
for (const sourceDocument of sourceDocuments)
|
|
1275
|
+
for (const lang of targetLanguages) {
|
|
1276
|
+
const payload = {
|
|
1277
|
+
_id: sourceDocument._id,
|
|
1278
|
+
_type: sourceDocument._type,
|
|
1279
|
+
language: lang,
|
|
1280
|
+
status: "queued",
|
|
1281
|
+
title: sourceDocument.title
|
|
1282
|
+
};
|
|
1283
|
+
dispatch({
|
|
1284
|
+
type: "SET_TRANSLATION_JOB_STATUS",
|
|
1285
|
+
payload
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
}, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
|
|
1289
|
+
(translation) => translation._key === targetLanguage
|
|
1290
|
+
)?.value._ref || null, determineTranslationTarget = ({
|
|
1291
|
+
translationMetadata,
|
|
1292
|
+
targetLanguage
|
|
1293
|
+
}) => {
|
|
1294
|
+
const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage);
|
|
1295
|
+
return existingTranslationId ? {
|
|
1296
|
+
// Update existing translation
|
|
1297
|
+
operation: "createOrReplace",
|
|
1298
|
+
_id: existingTranslationId
|
|
1299
|
+
} : {
|
|
1300
|
+
// Create new translation
|
|
1301
|
+
operation: "create"
|
|
1302
|
+
};
|
|
1303
|
+
}, getTranslationMetadata = async ({
|
|
1304
|
+
client,
|
|
1305
|
+
sourceDocument,
|
|
1306
|
+
sourceLanguage
|
|
1307
|
+
}) => {
|
|
1308
|
+
try {
|
|
1309
|
+
return await client.fetch(
|
|
1310
|
+
"*[_type == $tmType && references($sourceDocumentId)][0]",
|
|
1311
|
+
{
|
|
1312
|
+
tmType: TRANSLATION_METADATA_TYPE,
|
|
1313
|
+
sourceDocumentId: undraftId(sourceDocument._id)
|
|
1314
|
+
}
|
|
1315
|
+
) || await client.create({
|
|
1316
|
+
_type: TRANSLATION_METADATA_TYPE,
|
|
1317
|
+
translations: [
|
|
1318
|
+
{
|
|
1319
|
+
_key: sourceLanguage,
|
|
1320
|
+
_type: "internationalizedArrayReferenceValue",
|
|
1321
|
+
value: {
|
|
1322
|
+
_type: "reference",
|
|
1323
|
+
_ref: undraftId(sourceDocument._id),
|
|
1324
|
+
_weak: !0,
|
|
1325
|
+
_strengthenOnPublish: {
|
|
1326
|
+
_type: sourceDocument._type,
|
|
1327
|
+
_weak: !1
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
],
|
|
1332
|
+
translationHistory: []
|
|
1333
|
+
});
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
throw console.error(sourceDocument._id, "Error creating translation metadata:", error), error;
|
|
1336
|
+
}
|
|
1337
|
+
}, setJobStatus = (dispatch, _id, language, status, payload) => {
|
|
1338
|
+
dispatch({
|
|
1339
|
+
type: "SET_TRANSLATION_JOB_STATUS",
|
|
1340
|
+
payload: defaults(payload, {
|
|
1341
|
+
_id,
|
|
1342
|
+
language,
|
|
1343
|
+
status
|
|
1344
|
+
})
|
|
1345
|
+
});
|
|
1346
|
+
}, translateDocumentInPlace = async ({
|
|
1347
|
+
client,
|
|
1348
|
+
schemaId,
|
|
1349
|
+
dispatch,
|
|
1350
|
+
sourceDocument,
|
|
1351
|
+
languageFieldName,
|
|
1352
|
+
targetLanguage,
|
|
1353
|
+
targetId,
|
|
1354
|
+
patch
|
|
1355
|
+
}) => {
|
|
1356
|
+
try {
|
|
1357
|
+
return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating"), await client.agent.action.translate({
|
|
1358
|
+
schemaId,
|
|
1359
|
+
documentId: targetId,
|
|
1360
|
+
toLanguage: {
|
|
1361
|
+
title: targetLanguage,
|
|
1362
|
+
id: targetLanguage
|
|
1363
|
+
},
|
|
1364
|
+
fromLanguage: {
|
|
1365
|
+
title: sourceDocument[languageFieldName],
|
|
1366
|
+
id: sourceDocument[languageFieldName]
|
|
1367
|
+
},
|
|
1368
|
+
languageFieldPath: [languageFieldName],
|
|
1369
|
+
styleGuide: `
|
|
1370
|
+
**IMPORTANT:** Much of this document is already translated into the target language. The original version of this document in the source language
|
|
1371
|
+
was updated. The following patch instruction shows precisely how the source document was edited: $patchInstruction. Your job is to update this
|
|
1372
|
+
translation to including matching changes.
|
|
1373
|
+
`,
|
|
1374
|
+
styleGuideParams: {
|
|
1375
|
+
patchInstruction: JSON.stringify(patch, null, 2)
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error"), console.error("Error translating document:", error);
|
|
1380
|
+
}
|
|
1381
|
+
}, assembleLanguageSpecs = ({
|
|
1382
|
+
sourceDocument,
|
|
1383
|
+
languageFieldName,
|
|
1384
|
+
targetLanguage
|
|
1385
|
+
}) => {
|
|
1386
|
+
const sourceLanugageId = sourceDocument[languageFieldName];
|
|
1387
|
+
if (!sourceLanugageId)
|
|
1388
|
+
throw new Error("[assembleLanguages] Source language ID is missing");
|
|
1389
|
+
return {
|
|
1390
|
+
fromLanguage: {
|
|
1391
|
+
id: sourceLanugageId,
|
|
1392
|
+
title: sourceLanugageId
|
|
1393
|
+
},
|
|
1394
|
+
toLanguage: {
|
|
1395
|
+
id: targetLanguage,
|
|
1396
|
+
title: targetLanguage
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
}, translateEntireDocument = async ({
|
|
1400
|
+
dispatch,
|
|
1401
|
+
client,
|
|
1402
|
+
schemaId,
|
|
1403
|
+
sourceDocument,
|
|
1404
|
+
targetLanguage,
|
|
1405
|
+
languageFieldName,
|
|
1406
|
+
translationTarget,
|
|
1407
|
+
translationPrompts
|
|
1408
|
+
}) => {
|
|
1409
|
+
const logContext = {
|
|
1410
|
+
documentId: sourceDocument._id,
|
|
1411
|
+
targetLanguage,
|
|
1412
|
+
schemaId,
|
|
1413
|
+
operation: translationTarget.operation,
|
|
1414
|
+
targetId: translationTarget._id
|
|
1415
|
+
};
|
|
1416
|
+
devLog.group("translateEntireDocument", logContext), devLog.time("translateEntireDocument");
|
|
1417
|
+
try {
|
|
1418
|
+
const { toLanguage, fromLanguage } = assembleLanguageSpecs({
|
|
1419
|
+
sourceDocument,
|
|
1420
|
+
languageFieldName,
|
|
1421
|
+
targetLanguage
|
|
1422
|
+
});
|
|
1423
|
+
devLog.debug("Language specs assembled", {
|
|
1424
|
+
...logContext,
|
|
1425
|
+
toLanguage,
|
|
1426
|
+
fromLanguage
|
|
1427
|
+
});
|
|
1428
|
+
let extantTranslation = "";
|
|
1429
|
+
translationTarget._id && (devLog.debug("Checking for existing translation", {
|
|
1430
|
+
...logContext,
|
|
1431
|
+
targetId: translationTarget._id
|
|
1432
|
+
}), extantTranslation = await client.withConfig({
|
|
1433
|
+
perspective: "raw"
|
|
1434
|
+
}).fetch('*[_id in $ids] | order((_id match "drafts*") desc)[0]._id', {
|
|
1435
|
+
ids: [undraftId(translationTarget._id), draftId(translationTarget._id)]
|
|
1436
|
+
}), devLog.info("Extant translation found - will preserve existing content where possible", {
|
|
1437
|
+
...logContext,
|
|
1438
|
+
hasExtantTranslation: !!extantTranslation
|
|
1439
|
+
})), devLog.info("Starting Sanity agent translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating");
|
|
1440
|
+
const styleGuide = [
|
|
1441
|
+
translationPrompts.globalPrompt,
|
|
1442
|
+
translationPrompts.localeInstructions[targetLanguage]?.prompt,
|
|
1443
|
+
translationPrompts.localeInstructions[targetLanguage]?.glossaryTerms,
|
|
1444
|
+
extantTranslation ? UPDATE_EXISTING_TRANSLATION_PROMPT : null
|
|
1445
|
+
].filter(Boolean).join(`
|
|
1446
|
+
|
|
1447
|
+
`);
|
|
1448
|
+
devLog.info("Style guide generated", {
|
|
1449
|
+
...logContext,
|
|
1450
|
+
styleGuide
|
|
1451
|
+
});
|
|
1452
|
+
const newDocument = await client.agent.action.translate({
|
|
1453
|
+
schemaId,
|
|
1454
|
+
toLanguage,
|
|
1455
|
+
fromLanguage,
|
|
1456
|
+
documentId: sourceDocument._id,
|
|
1457
|
+
targetDocument: translationTarget,
|
|
1458
|
+
languageFieldPath: [languageFieldName],
|
|
1459
|
+
styleGuide,
|
|
1460
|
+
styleGuideParams: {
|
|
1461
|
+
extantTranslation
|
|
1462
|
+
},
|
|
1463
|
+
protectedPhrases: translationPrompts.protectedPhrases || []
|
|
1464
|
+
});
|
|
1465
|
+
return devLog.info("Document translation completed", {
|
|
1466
|
+
...logContext,
|
|
1467
|
+
newDocumentId: newDocument._id,
|
|
1468
|
+
newDocumentRev: newDocument._rev
|
|
1469
|
+
}), devLog.timeEnd("translateEntireDocument"), devLog.groupEnd(), newDocument;
|
|
1470
|
+
} catch (error) {
|
|
1471
|
+
throw devLog.error(
|
|
1472
|
+
"translateEntireDocument failed",
|
|
1473
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
1474
|
+
logContext
|
|
1475
|
+
), devLog.timeEnd("translateEntireDocument"), devLog.groupEnd(), error;
|
|
1476
|
+
}
|
|
1477
|
+
}, updateTranslationMetadata = async ({
|
|
1478
|
+
client,
|
|
1479
|
+
targetLanguage,
|
|
1480
|
+
sourceDocument,
|
|
1481
|
+
sourceLanguage,
|
|
1482
|
+
newDocument,
|
|
1483
|
+
translationMetadata,
|
|
1484
|
+
isNewTranslation
|
|
1485
|
+
}) => {
|
|
1486
|
+
try {
|
|
1487
|
+
const refToNewTranslation = {
|
|
1488
|
+
_key: targetLanguage,
|
|
1489
|
+
_type: "internationalizedArrayReferenceValue",
|
|
1490
|
+
value: {
|
|
1491
|
+
_type: "reference",
|
|
1492
|
+
_ref: undraftId(newDocument._id),
|
|
1493
|
+
_weak: !0,
|
|
1494
|
+
_strengthenOnPublish: {
|
|
1495
|
+
_type: "reference",
|
|
1496
|
+
_weak: !1
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}, newTranslationHistory = {
|
|
1500
|
+
_key: targetLanguage,
|
|
1501
|
+
language: targetLanguage,
|
|
1502
|
+
sourceId: undraftId(sourceDocument._id),
|
|
1503
|
+
sourceRev: sourceDocument._rev,
|
|
1504
|
+
sourceLanguage,
|
|
1505
|
+
sourceSnapshot: JSON.stringify(sourceDocument),
|
|
1506
|
+
targetId: newDocument._id,
|
|
1507
|
+
targetRev: newDocument._rev,
|
|
1508
|
+
targetSnapshot: JSON.stringify(newDocument)
|
|
1509
|
+
}, translationPatch = client.patch(translationMetadata._id).setIfMissing({ translations: [] });
|
|
1510
|
+
isNewTranslation ? await translationPatch.insert("after", "translations[-1]", [refToNewTranslation]).commit() : await translationPatch.insert("replace", `translations[_key == "${targetLanguage}"]`, [refToNewTranslation]).commit();
|
|
1511
|
+
const historyPatch = client.patch(translationMetadata._id).setIfMissing({ translationHistory: [] });
|
|
1512
|
+
translationMetadata.translationHistory?.some(
|
|
1513
|
+
(history) => history.language === targetLanguage
|
|
1514
|
+
) ? await historyPatch.insert("replace", `translationHistory[_key == "${targetLanguage}"]`, [newTranslationHistory]).commit() : await historyPatch.insert("after", "translationHistory[-1]", [newTranslationHistory]).commit();
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
throw console.error("Error updating translation metadata:", error), error;
|
|
1517
|
+
}
|
|
1518
|
+
}, translateDocument = async ({
|
|
1519
|
+
dispatch,
|
|
1520
|
+
client,
|
|
1521
|
+
sourceDocument,
|
|
1522
|
+
targetLanguage,
|
|
1523
|
+
languageFieldName,
|
|
1524
|
+
shouldOverwriteFreshTranslations,
|
|
1525
|
+
schemaId,
|
|
1526
|
+
shouldTranslateDiffs,
|
|
1527
|
+
translationPrompts
|
|
1528
|
+
}) => {
|
|
1529
|
+
const logContext = {
|
|
1530
|
+
documentId: sourceDocument._id,
|
|
1531
|
+
targetLanguage,
|
|
1532
|
+
schemaId,
|
|
1533
|
+
shouldTranslateDiffs,
|
|
1534
|
+
shouldOverwriteFreshTranslations
|
|
1535
|
+
};
|
|
1536
|
+
devLog.group("translateDocument", logContext), devLog.time("translateDocument"), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "preparing_translation");
|
|
1537
|
+
try {
|
|
1538
|
+
const sourceLanguage = sourceDocument[languageFieldName];
|
|
1539
|
+
if (devLog.debug("Checking source document language", {
|
|
1540
|
+
...logContext,
|
|
1541
|
+
sourceLanguage,
|
|
1542
|
+
languageFieldName
|
|
1543
|
+
}), !sourceLanguage) {
|
|
1544
|
+
const error = `Source document ${sourceDocument._id} does not have a language`;
|
|
1545
|
+
throw devLog.error(error, void 0, logContext), new Error(error);
|
|
1546
|
+
}
|
|
1547
|
+
devLog.debug("Getting translation metadata", logContext);
|
|
1548
|
+
const translationMetadata = await getTranslationMetadata({
|
|
1549
|
+
client,
|
|
1550
|
+
sourceDocument,
|
|
1551
|
+
sourceLanguage
|
|
1552
|
+
}), isAlreadyFreshTranslation = translationMetadata.translationHistory?.some(
|
|
1553
|
+
(history) => history.language === targetLanguage && history.sourceRev === sourceDocument._rev
|
|
1554
|
+
) ?? !1;
|
|
1555
|
+
if (devLog.debug("Checking if translation is already fresh", {
|
|
1556
|
+
...logContext,
|
|
1557
|
+
isAlreadyFreshTranslation,
|
|
1558
|
+
currentRev: sourceDocument._rev
|
|
1559
|
+
}), !shouldOverwriteFreshTranslations && isAlreadyFreshTranslation) {
|
|
1560
|
+
devLog.info("Skipping fresh translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "skipped", {
|
|
1561
|
+
targetId: translationMetadata.translations.find((t) => t._key === targetLanguage)?.value._ref
|
|
1562
|
+
}), devLog.timeEnd("translateDocument"), devLog.groupEnd();
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
const translationTarget = determineTranslationTarget({
|
|
1566
|
+
translationMetadata,
|
|
1567
|
+
targetLanguage
|
|
1568
|
+
}), isNewTranslation = translationTarget.operation === "create";
|
|
1569
|
+
devLog.debug("Translation target determined", {
|
|
1570
|
+
...logContext,
|
|
1571
|
+
operation: translationTarget.operation,
|
|
1572
|
+
isNewTranslation
|
|
1573
|
+
});
|
|
1574
|
+
let newDocument;
|
|
1575
|
+
if (!isNewTranslation && shouldTranslateDiffs) {
|
|
1576
|
+
devLog.info("Starting diff-based translation", logContext);
|
|
1577
|
+
const { currentTargetId, patch } = await patchTranslationTarget({
|
|
1578
|
+
client,
|
|
1579
|
+
currentSourceDocument: sourceDocument,
|
|
1580
|
+
sourceLanguage,
|
|
1581
|
+
translationMetadata,
|
|
1582
|
+
targetLanguage
|
|
1583
|
+
});
|
|
1584
|
+
if (!currentTargetId) {
|
|
1585
|
+
devLog.warn("No target ID found for diff translation", logContext), devLog.timeEnd("translateDocument"), devLog.groupEnd();
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
devLog.debug("Patching translation target", {
|
|
1589
|
+
...logContext,
|
|
1590
|
+
currentTargetId,
|
|
1591
|
+
hasPatch: !!patch
|
|
1592
|
+
}), newDocument = await translateDocumentInPlace({
|
|
1593
|
+
client,
|
|
1594
|
+
schemaId,
|
|
1595
|
+
dispatch,
|
|
1596
|
+
sourceDocument,
|
|
1597
|
+
languageFieldName,
|
|
1598
|
+
targetLanguage,
|
|
1599
|
+
targetId: currentTargetId,
|
|
1600
|
+
patch
|
|
1601
|
+
});
|
|
1602
|
+
} else
|
|
1603
|
+
devLog.info("Starting full document translation", {
|
|
1604
|
+
...logContext,
|
|
1605
|
+
translationTarget: translationTarget.operation
|
|
1606
|
+
}), newDocument = await translateEntireDocument({
|
|
1607
|
+
dispatch,
|
|
1608
|
+
client,
|
|
1609
|
+
schemaId,
|
|
1610
|
+
sourceDocument,
|
|
1611
|
+
targetLanguage,
|
|
1612
|
+
languageFieldName,
|
|
1613
|
+
translationTarget,
|
|
1614
|
+
translationPrompts
|
|
1615
|
+
});
|
|
1616
|
+
if (!newDocument)
|
|
1617
|
+
throw devLog.error("Failed to create translation document", void 0, logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error"), devLog.timeEnd("translateDocument"), devLog.groupEnd(), new Error("Failed to create translation document");
|
|
1618
|
+
return devLog.debug("Translation document created", {
|
|
1619
|
+
...logContext,
|
|
1620
|
+
newDocumentId: newDocument._id
|
|
1621
|
+
}), devLog.debug("Updating translation metadata", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "updating_metadata", {
|
|
1622
|
+
targetId: newDocument._id
|
|
1623
|
+
}), await updateTranslationMetadata({
|
|
1624
|
+
client,
|
|
1625
|
+
sourceDocument,
|
|
1626
|
+
sourceLanguage,
|
|
1627
|
+
newDocument,
|
|
1628
|
+
targetLanguage,
|
|
1629
|
+
translationMetadata,
|
|
1630
|
+
isNewTranslation
|
|
1631
|
+
}), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "completed"), devLog.info("Translation completed successfully", {
|
|
1632
|
+
...logContext,
|
|
1633
|
+
newDocumentId: newDocument._id
|
|
1634
|
+
}), devLog.timeEnd("translateDocument"), devLog.groupEnd(), newDocument._id;
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
throw devLog.error("Translation failed", error instanceof Error ? error : new Error(String(error)), logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error"), devLog.timeEnd("translateDocument"), devLog.groupEnd(), error;
|
|
1637
|
+
}
|
|
1638
|
+
}, patchTranslationTarget = async ({
|
|
1639
|
+
client,
|
|
1640
|
+
currentSourceDocument,
|
|
1641
|
+
translationMetadata,
|
|
1642
|
+
targetLanguage
|
|
1643
|
+
}) => {
|
|
1644
|
+
const sourceSnapshotString = translationMetadata.translationHistory.find(
|
|
1645
|
+
(h) => undraftId(h.sourceId) === undraftId(currentSourceDocument._id)
|
|
1646
|
+
)?.sourceSnapshot;
|
|
1647
|
+
if (!sourceSnapshotString)
|
|
1648
|
+
return {};
|
|
1649
|
+
const sourceSnapshot = JSON.parse(sourceSnapshotString), targetId = translationMetadata.translations.find((t) => t._key === targetLanguage)?.value._ref;
|
|
1650
|
+
if (!targetId)
|
|
1651
|
+
return {};
|
|
1652
|
+
const currentTargetId = await client.fetch(
|
|
1653
|
+
'*[_id in $ids] | order((_id match "drafts*") desc)[0]._id',
|
|
1654
|
+
{ ids: [undraftId(targetId), draftId(targetId)] }
|
|
1655
|
+
);
|
|
1656
|
+
if (!currentTargetId)
|
|
1657
|
+
return console.log(`No document found for ${targetId}`), {};
|
|
1658
|
+
const patch = diffPatch(sourceSnapshot, currentSourceDocument, {
|
|
1659
|
+
id: draftId(currentTargetId)
|
|
1660
|
+
});
|
|
1661
|
+
return { currentTargetId, patch };
|
|
1662
|
+
}, translationSemaphore = new Semaphore(2), translateDocumentSet = async ({
|
|
1663
|
+
client,
|
|
1664
|
+
sourceDocument,
|
|
1665
|
+
targetLanguages,
|
|
1666
|
+
dispatch,
|
|
1667
|
+
languageFieldName,
|
|
1668
|
+
shouldOverwriteFreshTranslations,
|
|
1669
|
+
schemaId,
|
|
1670
|
+
references = [],
|
|
1671
|
+
maxConcurrentTranslationJobs,
|
|
1672
|
+
shouldTranslateDiffs
|
|
1673
|
+
}) => {
|
|
1674
|
+
const logContext = {
|
|
1675
|
+
documentId: sourceDocument._id,
|
|
1676
|
+
targetLanguages,
|
|
1677
|
+
referencesCount: references.length,
|
|
1678
|
+
maxConcurrentJobs: maxConcurrentTranslationJobs,
|
|
1679
|
+
shouldTranslateDiffs,
|
|
1680
|
+
shouldOverwriteFreshTranslations
|
|
1681
|
+
};
|
|
1682
|
+
devLog.group("translateDocumentSet", logContext), devLog.time("translateDocumentSet");
|
|
1683
|
+
try {
|
|
1684
|
+
const sourceLanguage = sourceDocument[languageFieldName];
|
|
1685
|
+
if (!sourceLanguage)
|
|
1686
|
+
throw devLog.error("Source language not found"), new Error("Source language not found");
|
|
1687
|
+
maxConcurrentTranslationJobs && (devLog.debug("Updating translation semaphore permits", {
|
|
1688
|
+
...logContext,
|
|
1689
|
+
newPermits: maxConcurrentTranslationJobs
|
|
1690
|
+
}), translationSemaphore.updatePermits(maxConcurrentTranslationJobs)), devLog.info("Starting document set translation", logContext), dispatch({
|
|
1691
|
+
type: "SET_OVERALL_STATUS",
|
|
1692
|
+
payload: "translating"
|
|
1693
|
+
});
|
|
1694
|
+
const sourceDocuments = [
|
|
1695
|
+
{
|
|
1696
|
+
_id: sourceDocument._id,
|
|
1697
|
+
_type: sourceDocument._type,
|
|
1698
|
+
// TODO: Handle this properly
|
|
1699
|
+
title: sourceDocument.name || sourceDocument.title
|
|
1700
|
+
},
|
|
1701
|
+
...references
|
|
1702
|
+
], sourceDocumentIds = sourceDocuments.map((doc) => doc._id), targetDocumentIds = [];
|
|
1703
|
+
devLog.debug("Prepared document set for translation", {
|
|
1704
|
+
...logContext,
|
|
1705
|
+
sourceDocumentIds: sourceDocumentIds.length,
|
|
1706
|
+
totalTranslationJobs: sourceDocumentIds.length * targetLanguages.length
|
|
1707
|
+
});
|
|
1708
|
+
const translationPrompts = await fetchTranslationPrompts({
|
|
1709
|
+
client,
|
|
1710
|
+
sourceLanguage,
|
|
1711
|
+
targetLanguages
|
|
1712
|
+
});
|
|
1713
|
+
devLog.info("Translation prompts", translationPrompts), setupTranslationJobStatuses(dispatch, sourceDocuments, targetLanguages);
|
|
1714
|
+
const translationTasks = sourceDocumentIds.flatMap(
|
|
1715
|
+
(documentId) => targetLanguages.map(async (targetLanguage) => {
|
|
1716
|
+
await translationSemaphore.withPermit(async () => {
|
|
1717
|
+
const document = await client.withConfig({
|
|
1718
|
+
// This enables us to detect documents that might be stale due to unpublished changes
|
|
1719
|
+
perspective: "raw"
|
|
1720
|
+
}).fetch("*[_id == $id][0]", {
|
|
1721
|
+
id: documentId
|
|
1722
|
+
});
|
|
1723
|
+
if (document) {
|
|
1724
|
+
const targetDocumentId = await translateDocument({
|
|
1725
|
+
dispatch,
|
|
1726
|
+
client,
|
|
1727
|
+
sourceDocument: document,
|
|
1728
|
+
targetLanguage,
|
|
1729
|
+
languageFieldName,
|
|
1730
|
+
shouldOverwriteFreshTranslations,
|
|
1731
|
+
schemaId,
|
|
1732
|
+
shouldTranslateDiffs,
|
|
1733
|
+
translationPrompts
|
|
1734
|
+
});
|
|
1735
|
+
targetDocumentId && targetDocumentIds.push(targetDocumentId);
|
|
1736
|
+
}
|
|
1737
|
+
});
|
|
1738
|
+
})
|
|
1739
|
+
);
|
|
1740
|
+
await Promise.all(translationTasks), devLog.info("All translation tasks completed", {
|
|
1741
|
+
...logContext,
|
|
1742
|
+
targetDocumentIds: targetDocumentIds.length
|
|
1743
|
+
}), dispatch({
|
|
1744
|
+
type: "SET_OVERALL_STATUS",
|
|
1745
|
+
payload: "linking_references"
|
|
1746
|
+
}), devLog.debug("Starting reference localization", {
|
|
1747
|
+
...logContext,
|
|
1748
|
+
documentsToLocalize: targetDocumentIds.length
|
|
1749
|
+
}), await Promise.all(
|
|
1750
|
+
targetDocumentIds.map(async (documentId) => {
|
|
1751
|
+
await localizeReferences({
|
|
1752
|
+
client,
|
|
1753
|
+
documentId,
|
|
1754
|
+
languageFieldName
|
|
1755
|
+
});
|
|
1756
|
+
})
|
|
1757
|
+
), devLog.info("Document set translation completed successfully", logContext), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" });
|
|
1758
|
+
} catch (error) {
|
|
1759
|
+
return devLog.error("translateDocumentSet failed", error instanceof Error ? error : new Error(String(error)), logContext), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), Promise.reject(error);
|
|
1760
|
+
}
|
|
1761
|
+
}, ActionButtonBar = ({ setIsDialogOpen }) => {
|
|
1762
|
+
const {
|
|
1763
|
+
document,
|
|
1764
|
+
config,
|
|
1765
|
+
overallStatus,
|
|
1766
|
+
dispatch,
|
|
1767
|
+
references,
|
|
1768
|
+
selectedTargetLanguages,
|
|
1769
|
+
shouldOverwriteFreshTranslations,
|
|
1770
|
+
shouldTranslateDiffs
|
|
1771
|
+
} = useTranslationContext(), client = useClient({ apiVersion: "vX" });
|
|
1772
|
+
return /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
|
|
1773
|
+
/* @__PURE__ */ jsxs(Flex, { justify: "flex-start", gap: 2, children: [
|
|
1774
|
+
/* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: () => {
|
|
1775
|
+
dispatch({
|
|
1776
|
+
type: "SET_SELECTED_TARGET_LANGUAGES",
|
|
1777
|
+
payload: config.supportedLanguages.map((lang) => lang.id).filter((id) => id !== document.language)
|
|
1778
|
+
});
|
|
1779
|
+
}, children: "Select All" }),
|
|
1780
|
+
/* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: () => {
|
|
1781
|
+
dispatch({
|
|
1782
|
+
type: "SET_SELECTED_TARGET_LANGUAGES",
|
|
1783
|
+
payload: []
|
|
1784
|
+
});
|
|
1785
|
+
}, children: "Select None" })
|
|
1786
|
+
] }),
|
|
1787
|
+
/* @__PURE__ */ jsxs(Flex, { justify: "flex-end", gap: 2, children: [
|
|
1788
|
+
/* @__PURE__ */ jsx(Button, { onClick: () => setIsDialogOpen(!1), mode: "ghost", size: 2, children: "Cancel" }),
|
|
1789
|
+
/* @__PURE__ */ jsx(
|
|
1790
|
+
Button,
|
|
1791
|
+
{
|
|
1792
|
+
mode: "ghost",
|
|
1793
|
+
tone: "positive",
|
|
1794
|
+
size: 2,
|
|
1795
|
+
onClick: () => {
|
|
1796
|
+
translateDocumentSet({
|
|
1797
|
+
sourceDocument: document,
|
|
1798
|
+
targetLanguages: selectedTargetLanguages,
|
|
1799
|
+
client,
|
|
1800
|
+
dispatch,
|
|
1801
|
+
languageFieldName: config.languageFieldName,
|
|
1802
|
+
schemaId: config.schemaId,
|
|
1803
|
+
references,
|
|
1804
|
+
shouldOverwriteFreshTranslations,
|
|
1805
|
+
maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
|
|
1806
|
+
shouldTranslateDiffs
|
|
1807
|
+
});
|
|
1808
|
+
},
|
|
1809
|
+
disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus),
|
|
1810
|
+
children: "Translate"
|
|
1811
|
+
}
|
|
1812
|
+
)
|
|
1813
|
+
] })
|
|
1814
|
+
] });
|
|
1815
|
+
}, LanguageCheckbox = ({ lang, isLoading }) => {
|
|
1816
|
+
const { document, dispatch, selectedTargetLanguages, translationMetadata } = useTranslationContext(), isDisabled = lang.id === document.language, isChecked = selectedTargetLanguages.includes(lang.id), hasTranslation = translationMetadata?.translations?.some((translation) => translation._key === lang.id), isStale = hasTranslation && translationMetadata?.translationHistory?.some(
|
|
1817
|
+
(history) => history.language === lang.id && history.sourceRev !== document._rev
|
|
1818
|
+
), handleChange = () => {
|
|
1819
|
+
if (isDisabled) return;
|
|
1820
|
+
const updatedLanguages = isChecked ? selectedTargetLanguages.filter((langId) => langId !== lang.id) : [...selectedTargetLanguages, lang.id];
|
|
1821
|
+
dispatch({
|
|
1822
|
+
type: "SET_SELECTED_TARGET_LANGUAGES",
|
|
1823
|
+
payload: updatedLanguages
|
|
1824
|
+
});
|
|
1825
|
+
};
|
|
1826
|
+
return isLoading ? /* @__PURE__ */ jsx(Button, { type: "button", mode: "bleed", size: 2, onClick: handleChange, disabled: !0, muted: !0, children: /* @__PURE__ */ jsx(Flex, { align: "center", justify: "center", paddingY: 3, children: /* @__PURE__ */ jsx(Spinner, { size: 1 }) }) }) : /* @__PURE__ */ jsx(
|
|
1827
|
+
Button,
|
|
1828
|
+
{
|
|
1829
|
+
type: "button",
|
|
1830
|
+
mode: isChecked ? "ghost" : "bleed",
|
|
1831
|
+
size: 2,
|
|
1832
|
+
onClick: handleChange,
|
|
1833
|
+
disabled: isDisabled,
|
|
1834
|
+
tone: isChecked ? "primary" : "default",
|
|
1835
|
+
muted: !0,
|
|
1836
|
+
children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, style: { cursor: "pointer" }, children: [
|
|
1837
|
+
/* @__PURE__ */ jsxs(Inline, { space: 3, children: [
|
|
1838
|
+
/* @__PURE__ */ jsx(Checkbox, { checked: isChecked, onChange: handleChange, label: lang.title, disabled: isDisabled }),
|
|
1839
|
+
/* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 3, children: [
|
|
1840
|
+
/* @__PURE__ */ jsx(Text, { children: lang.title }),
|
|
1841
|
+
isDisabled ? /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "The current language" }) : /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: hasTranslation && !isDisabled ? "Already translated" : "Not translated" })
|
|
1842
|
+
] })
|
|
1843
|
+
] }),
|
|
1844
|
+
!isDisabled && hasTranslation && /* @__PURE__ */ jsx(Flex, { flex: 1, justify: "flex-end", children: /* @__PURE__ */ jsx(Badge, { tone: isStale ? "caution" : "positive", size: 3, muted: !0, children: isStale ? "Stale" : "Fresh" }) })
|
|
1845
|
+
] })
|
|
1846
|
+
}
|
|
1847
|
+
);
|
|
1848
|
+
}, LanguageSelectionPanel = () => {
|
|
1849
|
+
const { document, config } = useTranslationContext(), { loading } = useTranslationMetadata({
|
|
1850
|
+
documentId: document._id
|
|
1851
|
+
});
|
|
1852
|
+
return /* @__PURE__ */ jsx(Card, { padding: 4, muted: !0, border: !0, children: /* @__PURE__ */ jsx(Grid, { columns: [1, 2], gap: 3, children: config?.supportedLanguages.map((lang) => /* @__PURE__ */ jsx(LanguageCheckbox, { lang, isLoading: loading }, lang.id)) }) });
|
|
1853
|
+
}, assembleReferences = async ({
|
|
1854
|
+
client,
|
|
1855
|
+
sourceDocument,
|
|
1856
|
+
maxReferenceDepth,
|
|
1857
|
+
translatableSchemaTypes = []
|
|
1858
|
+
}) => {
|
|
1859
|
+
if (!client)
|
|
1860
|
+
throw new Error("assembleReferences: client is required");
|
|
1861
|
+
if (!sourceDocument || !sourceDocument._id)
|
|
1862
|
+
throw new Error("assembleReferences: sourceDocument with _id is required");
|
|
1863
|
+
if (typeof maxReferenceDepth != "number" || maxReferenceDepth < 0)
|
|
1864
|
+
throw new Error("assembleReferences: maxReferenceDepth must be a non-negative number");
|
|
1865
|
+
const references = /* @__PURE__ */ new Set(), documentCache = /* @__PURE__ */ new Map(), errors = [];
|
|
1866
|
+
let currentDepthDocuments = [sourceDocument];
|
|
1867
|
+
for (let depth = 0; depth < maxReferenceDepth && currentDepthDocuments.length > 0; depth++) {
|
|
1868
|
+
const allRefIds = /* @__PURE__ */ new Set();
|
|
1869
|
+
for (const document of currentDepthDocuments)
|
|
1870
|
+
try {
|
|
1871
|
+
const refResults = findKeyValues(document, "_ref");
|
|
1872
|
+
for (const result2 of refResults) {
|
|
1873
|
+
const refId = result2.value;
|
|
1874
|
+
typeof refId == "string" && refId.length > 0 && !references.has(refId) && !documentCache.has(refId) && allRefIds.add(refId);
|
|
1875
|
+
}
|
|
1876
|
+
} catch (error) {
|
|
1877
|
+
console.error("assembleReferences: Error finding references in document:", error);
|
|
1878
|
+
}
|
|
1879
|
+
if (allRefIds.size === 0)
|
|
1880
|
+
break;
|
|
1881
|
+
const refIdArray = Array.from(allRefIds);
|
|
1882
|
+
try {
|
|
1883
|
+
const batchResults = await client.fetch("*[_id in $ids]", { ids: refIdArray }), fetchedDocuments = /* @__PURE__ */ new Map();
|
|
1884
|
+
for (const doc of batchResults)
|
|
1885
|
+
fetchedDocuments.set(doc._id, doc);
|
|
1886
|
+
const nextDepthDocuments = [];
|
|
1887
|
+
for (const refId of refIdArray) {
|
|
1888
|
+
const fetchedDoc = fetchedDocuments.get(refId);
|
|
1889
|
+
if (fetchedDoc) {
|
|
1890
|
+
if (translatableSchemaTypes.length > 0 && !translatableSchemaTypes.includes(fetchedDoc._type)) {
|
|
1891
|
+
documentCache.set(refId, null);
|
|
1892
|
+
continue;
|
|
1893
|
+
}
|
|
1894
|
+
documentCache.set(refId, fetchedDoc), references.add(refId), nextDepthDocuments.push(fetchedDoc);
|
|
1895
|
+
} else
|
|
1896
|
+
documentCache.set(refId, null), console.warn(`assembleReferences: Document with _id "${refId}" not found`);
|
|
1897
|
+
}
|
|
1898
|
+
currentDepthDocuments = nextDepthDocuments;
|
|
1899
|
+
} catch (fetchError) {
|
|
1900
|
+
const error = fetchError instanceof Error ? fetchError : new Error(String(fetchError));
|
|
1901
|
+
errors.push({ refId: `batch(${refIdArray.join(",")})`, error }), console.error("assembleReferences: Failed to batch fetch documents:", error.message);
|
|
1902
|
+
break;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
errors.length > 0 && console.warn(`assembleReferences: Encountered ${errors.length} error(s) while fetching references`);
|
|
1906
|
+
const result = [];
|
|
1907
|
+
for (const refId of references) {
|
|
1908
|
+
const cachedDoc = documentCache.get(refId);
|
|
1909
|
+
cachedDoc && result.push({
|
|
1910
|
+
_id: cachedDoc._id,
|
|
1911
|
+
_type: cachedDoc._type,
|
|
1912
|
+
title: cachedDoc.name || cachedDoc.title
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
return result;
|
|
1916
|
+
}, ReferenceConfigPanel = () => {
|
|
1917
|
+
const { dispatch, document, shouldTranslateReferences, maxReferenceDepth, config, references } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [isLoadingReferences, setIsLoadingReferences] = useState(!1);
|
|
1918
|
+
useEffect(() => {
|
|
1919
|
+
(async () => {
|
|
1920
|
+
if (setIsLoadingReferences(!0), !shouldTranslateReferences) {
|
|
1921
|
+
dispatch({
|
|
1922
|
+
type: "SET_REFERENCES",
|
|
1923
|
+
payload: []
|
|
1924
|
+
});
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
const newReferences = await assembleReferences({
|
|
1928
|
+
client,
|
|
1929
|
+
sourceDocument: document,
|
|
1930
|
+
maxReferenceDepth,
|
|
1931
|
+
translatableSchemaTypes: config.translatableSchemaTypes
|
|
1932
|
+
});
|
|
1933
|
+
dispatch({
|
|
1934
|
+
type: "SET_REFERENCES",
|
|
1935
|
+
payload: newReferences
|
|
1936
|
+
}), setIsLoadingReferences(!1);
|
|
1937
|
+
})();
|
|
1938
|
+
}, [document, client, dispatch, shouldTranslateReferences, maxReferenceDepth, config.translatableSchemaTypes]);
|
|
1939
|
+
const handleMaxReferenceDepthChange = async (event) => {
|
|
1940
|
+
dispatch({
|
|
1941
|
+
type: "SET_MAX_REFERENCE_DEPTH",
|
|
1942
|
+
payload: Number.parseInt(event.target.value)
|
|
1943
|
+
});
|
|
1944
|
+
}, handleShouldTranslateChange = () => {
|
|
1945
|
+
dispatch({
|
|
1946
|
+
type: "SET_SHOULD_TRANSLATE_REFERENCES",
|
|
1947
|
+
payload: !shouldTranslateReferences
|
|
1948
|
+
});
|
|
1949
|
+
};
|
|
1950
|
+
return /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
1951
|
+
/* @__PURE__ */ jsxs(Flex, { justify: "space-between", align: "center", children: [
|
|
1952
|
+
/* @__PURE__ */ jsx(
|
|
1953
|
+
Button,
|
|
1954
|
+
{
|
|
1955
|
+
type: "button",
|
|
1956
|
+
mode: shouldTranslateReferences ? "ghost" : "bleed",
|
|
1957
|
+
size: 2,
|
|
1958
|
+
onClick: handleShouldTranslateChange,
|
|
1959
|
+
tone: shouldTranslateReferences ? "primary" : "neutral",
|
|
1960
|
+
children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
1961
|
+
/* @__PURE__ */ jsx(
|
|
1962
|
+
Checkbox,
|
|
1963
|
+
{
|
|
1964
|
+
checked: shouldTranslateReferences,
|
|
1965
|
+
onChange: handleShouldTranslateChange,
|
|
1966
|
+
label: shouldTranslateReferences ? "Disable" : "Enable"
|
|
1967
|
+
}
|
|
1968
|
+
),
|
|
1969
|
+
/* @__PURE__ */ jsx(Text, { children: "Translate References" })
|
|
1970
|
+
] })
|
|
1971
|
+
}
|
|
1972
|
+
),
|
|
1973
|
+
shouldTranslateReferences ? /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
1974
|
+
/* @__PURE__ */ jsx(Text, { children: "Max Depth:" }),
|
|
1975
|
+
/* @__PURE__ */ jsxs(
|
|
1976
|
+
Select,
|
|
1977
|
+
{
|
|
1978
|
+
disabled: !shouldTranslateReferences,
|
|
1979
|
+
placeholder: "Depth",
|
|
1980
|
+
onChange: handleMaxReferenceDepthChange,
|
|
1981
|
+
value: maxReferenceDepth.toString(),
|
|
1982
|
+
children: [
|
|
1983
|
+
/* @__PURE__ */ jsx("option", { value: 1, children: "1" }),
|
|
1984
|
+
/* @__PURE__ */ jsx("option", { value: 2, children: "2" }),
|
|
1985
|
+
/* @__PURE__ */ jsx("option", { value: 3, children: "3" })
|
|
1986
|
+
]
|
|
1987
|
+
}
|
|
1988
|
+
)
|
|
1989
|
+
] }) : null
|
|
1990
|
+
] }),
|
|
1991
|
+
shouldTranslateReferences ? /* @__PURE__ */ jsx(Flex, { padding: 2, justify: "flex-end", children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: isLoadingReferences ? "Loading references..." : references.length === 0 ? "No translatable references found." : `${references.length} translatable references found.` }) }) : null
|
|
1992
|
+
] });
|
|
1993
|
+
}, WriteSettingsPanel = () => {
|
|
1994
|
+
const { dispatch, shouldOverwriteFreshTranslations, shouldTranslateDiffs } = useTranslationContext(), handleShouldOverwriteFreshTranslationsChange = () => {
|
|
1995
|
+
dispatch({
|
|
1996
|
+
type: "SET_SHOULD_OVERWRITE_FRESH_TRANSLATIONS",
|
|
1997
|
+
payload: !shouldOverwriteFreshTranslations
|
|
1998
|
+
});
|
|
1999
|
+
};
|
|
2000
|
+
return /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
2001
|
+
/* @__PURE__ */ jsx(ReferenceConfigPanel, {}),
|
|
2002
|
+
/* @__PURE__ */ jsx(Flex, { justify: "space-between", align: "center", children: /* @__PURE__ */ jsx(
|
|
2003
|
+
Button,
|
|
2004
|
+
{
|
|
2005
|
+
type: "button",
|
|
2006
|
+
mode: shouldOverwriteFreshTranslations ? "ghost" : "bleed",
|
|
2007
|
+
size: 2,
|
|
2008
|
+
onClick: handleShouldOverwriteFreshTranslationsChange,
|
|
2009
|
+
tone: shouldOverwriteFreshTranslations ? "primary" : "neutral",
|
|
2010
|
+
children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
2011
|
+
/* @__PURE__ */ jsx(
|
|
2012
|
+
Checkbox,
|
|
2013
|
+
{
|
|
2014
|
+
checked: shouldOverwriteFreshTranslations,
|
|
2015
|
+
onChange: handleShouldOverwriteFreshTranslationsChange,
|
|
2016
|
+
label: shouldOverwriteFreshTranslations ? "Disable" : "Enable"
|
|
2017
|
+
}
|
|
2018
|
+
),
|
|
2019
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
2020
|
+
/* @__PURE__ */ jsx(Text, { children: "Overwrite Fresh Translations" }),
|
|
2021
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "All existing translations will be overwritten even if they are fresh." })
|
|
2022
|
+
] })
|
|
2023
|
+
] })
|
|
2024
|
+
}
|
|
2025
|
+
) }),
|
|
2026
|
+
/* @__PURE__ */ jsx(Flex, { justify: "space-between", align: "center", children: /* @__PURE__ */ jsx(
|
|
2027
|
+
Button,
|
|
2028
|
+
{
|
|
2029
|
+
type: "button",
|
|
2030
|
+
mode: shouldTranslateDiffs ? "ghost" : "bleed",
|
|
2031
|
+
size: 2,
|
|
2032
|
+
onClick: () => {
|
|
2033
|
+
dispatch({
|
|
2034
|
+
type: "SET_SHOULD_TRANSLATE_DIFFS",
|
|
2035
|
+
payload: !shouldTranslateDiffs
|
|
2036
|
+
});
|
|
2037
|
+
},
|
|
2038
|
+
tone: shouldTranslateDiffs ? "primary" : "neutral",
|
|
2039
|
+
children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
2040
|
+
/* @__PURE__ */ jsx(
|
|
2041
|
+
Checkbox,
|
|
2042
|
+
{
|
|
2043
|
+
checked: shouldTranslateDiffs,
|
|
2044
|
+
onChange: handleShouldOverwriteFreshTranslationsChange,
|
|
2045
|
+
label: shouldTranslateDiffs ? "Disable" : "Enable"
|
|
2046
|
+
}
|
|
2047
|
+
),
|
|
2048
|
+
/* @__PURE__ */ jsxs(Stack, { space: 3, children: [
|
|
2049
|
+
/* @__PURE__ */ jsxs(Inline, { space: 1, children: [
|
|
2050
|
+
/* @__PURE__ */ jsx(Text, { children: "Translate Diffs" }),
|
|
2051
|
+
/* @__PURE__ */ jsx(Badge, { tone: "caution", children: "Experimental" })
|
|
2052
|
+
] }),
|
|
2053
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Only re-translate content that has changed in the source document." })
|
|
2054
|
+
] })
|
|
2055
|
+
] })
|
|
2056
|
+
}
|
|
2057
|
+
) })
|
|
2058
|
+
] });
|
|
2059
|
+
}, TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
|
|
2060
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
2061
|
+
/* @__PURE__ */ jsx(Label, { children: "Languages" }),
|
|
2062
|
+
/* @__PURE__ */ jsx(LanguageSelectionPanel, {})
|
|
2063
|
+
] }),
|
|
2064
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
2065
|
+
/* @__PURE__ */ jsx(Label, { children: "Settings" }),
|
|
2066
|
+
/* @__PURE__ */ jsx(Card, { padding: 4, border: !0, muted: !0, children: /* @__PURE__ */ jsx(WriteSettingsPanel, {}) })
|
|
2067
|
+
] }),
|
|
2068
|
+
/* @__PURE__ */ jsx(ActionButtonBar, { setIsDialogOpen })
|
|
2069
|
+
] }), TERMINAL_STATUSES = ["completed", "skipped", "error"], TranslationStatusPanel = () => {
|
|
2070
|
+
const { overallStatus, translationJobs } = useTranslationContext(), headline = useMemo(
|
|
2071
|
+
() => overallStatus === "completed" ? "Translation completed" : "Translation in progress",
|
|
2072
|
+
[overallStatus]
|
|
2073
|
+
), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]);
|
|
2074
|
+
return /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
|
|
2075
|
+
/* @__PURE__ */ jsx(Heading, { size: 2, children: headline }),
|
|
2076
|
+
/* @__PURE__ */ jsx(ProgressBar, { progress: jobs.filter((job) => TERMINAL_STATUSES.includes(job.status)).length, max: jobs.length }),
|
|
2077
|
+
/* @__PURE__ */ jsxs(Stack, { space: 3, children: [
|
|
2078
|
+
/* @__PURE__ */ jsx(Label, { children: "Translations" }),
|
|
2079
|
+
/* @__PURE__ */ jsx(Card, { padding: 2, tone: "transparent", children: /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsx("ul", { children: jobs.map((job) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(Box, { padding: 3, children: /* @__PURE__ */ jsxs(Flex, { justify: "space-between", align: "center", children: [
|
|
2080
|
+
/* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
2081
|
+
/* @__PURE__ */ jsx(Flex, { align: "center", justify: "center", style: { width: "24px", height: "24px" }, children: ["completed", "skipped"].includes(job.status) ? /* @__PURE__ */ jsx(CheckmarkIcon, { fontSize: 24 }) : job.status === "queued" ? /* @__PURE__ */ jsx(CircleIcon, { fontSize: 24 }) : /* @__PURE__ */ jsx(Spinner, {}) }),
|
|
2082
|
+
/* @__PURE__ */ jsxs(Stack, { space: 3, children: [
|
|
2083
|
+
/* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
2084
|
+
/* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: job.title || "Untitled Document" }),
|
|
2085
|
+
/* @__PURE__ */ jsx(ArrowRightIcon, { fontSize: 18 }),
|
|
2086
|
+
/* @__PURE__ */ jsx(Code, { size: 1, children: job.language })
|
|
2087
|
+
] }),
|
|
2088
|
+
/* @__PURE__ */ jsxs(Code, { size: 1, children: [
|
|
2089
|
+
job._type,
|
|
2090
|
+
" \u2014 ",
|
|
2091
|
+
job._id.slice(-6)
|
|
2092
|
+
] })
|
|
2093
|
+
] })
|
|
2094
|
+
] }),
|
|
2095
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
|
|
2096
|
+
job.status === "skipped" ? /* @__PURE__ */ jsx(SkippedBadge, {}) : null,
|
|
2097
|
+
job.targetId ? /* @__PURE__ */ jsx(
|
|
2098
|
+
TranslationOpenerButton,
|
|
2099
|
+
{
|
|
2100
|
+
target: {
|
|
2101
|
+
_id: job.targetId,
|
|
2102
|
+
_type: job._type
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
) : null
|
|
2106
|
+
] })
|
|
2107
|
+
] }) }) }, job._id)) }) }) })
|
|
2108
|
+
] })
|
|
2109
|
+
] });
|
|
2110
|
+
}, ProgressBar = ({ progress, max = 100 }) => {
|
|
2111
|
+
const percentage = Math.min(progress / max * 100, 100);
|
|
2112
|
+
return /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
2113
|
+
/* @__PURE__ */ jsx(
|
|
2114
|
+
"div",
|
|
2115
|
+
{
|
|
2116
|
+
style: {
|
|
2117
|
+
width: "100%",
|
|
2118
|
+
height: "8px",
|
|
2119
|
+
backgroundColor: "var(--card-border-color)",
|
|
2120
|
+
borderRadius: "4px",
|
|
2121
|
+
overflow: "hidden"
|
|
2122
|
+
},
|
|
2123
|
+
children: /* @__PURE__ */ jsx(
|
|
2124
|
+
"div",
|
|
2125
|
+
{
|
|
2126
|
+
style: {
|
|
2127
|
+
width: `${percentage}%`,
|
|
2128
|
+
height: "100%",
|
|
2129
|
+
backgroundColor: "var(--card-accent-fg-color)",
|
|
2130
|
+
borderRadius: "4px",
|
|
2131
|
+
transition: "width 0.3s ease-in-out"
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
)
|
|
2135
|
+
}
|
|
2136
|
+
),
|
|
2137
|
+
/* @__PURE__ */ jsxs(Flex, { justify: "space-between", align: "center", children: [
|
|
2138
|
+
/* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
|
|
2139
|
+
percentage.toFixed(0),
|
|
2140
|
+
"%"
|
|
2141
|
+
] }),
|
|
2142
|
+
/* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
|
|
2143
|
+
progress,
|
|
2144
|
+
" / ",
|
|
2145
|
+
max,
|
|
2146
|
+
" jobs completed."
|
|
2147
|
+
] })
|
|
2148
|
+
] })
|
|
2149
|
+
] });
|
|
2150
|
+
}, TranslationOpenerButton = ({ target }) => {
|
|
2151
|
+
const openInNewPane = useOpenInNewPane(target._id, target._type);
|
|
2152
|
+
return /* @__PURE__ */ jsx(Button, { mode: "bleed", text: "View", iconRight: PanelRightIcon, onClick: () => openInNewPane() });
|
|
2153
|
+
}, SkippedBadge = () => /* @__PURE__ */ jsx(
|
|
2154
|
+
Tooltip,
|
|
2155
|
+
{
|
|
2156
|
+
portal: !0,
|
|
2157
|
+
content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "There was already a fresh translation for this document and language." }) }),
|
|
2158
|
+
children: /* @__PURE__ */ jsx(Badge, { tone: "neutral", children: "Skipped" })
|
|
2159
|
+
}
|
|
2160
|
+
), STATES_IN_WHICH_TO_SHOW_CONFIG = ["fetching_metadata", "idle"], STATES_IN_WHICH_TO_SHOW_TRANSLATION_PROGRESS = [
|
|
2161
|
+
"translating",
|
|
2162
|
+
"linking_references",
|
|
2163
|
+
"completed"
|
|
2164
|
+
], STATES_IN_WHICH_TO_SHOW_ERROR = ["error"], TranslationDialog = ({ setIsDialogOpen }) => {
|
|
2165
|
+
const { overallStatus, document } = useTranslationContext();
|
|
2166
|
+
return devLog.debug("TranslationDialog rendered", {
|
|
2167
|
+
documentId: document._id,
|
|
2168
|
+
overallStatus,
|
|
2169
|
+
component: "TranslationDialog"
|
|
2170
|
+
}), STATES_IN_WHICH_TO_SHOW_CONFIG.includes(overallStatus) ? (devLog.debug("Showing translation config view", {
|
|
2171
|
+
documentId: document._id,
|
|
2172
|
+
overallStatus,
|
|
2173
|
+
component: "TranslationDialog"
|
|
2174
|
+
}), /* @__PURE__ */ jsx(TranslationConfigView, { setIsDialogOpen })) : STATES_IN_WHICH_TO_SHOW_TRANSLATION_PROGRESS.includes(overallStatus) ? (devLog.debug("Showing translation progress panel", {
|
|
2175
|
+
documentId: document._id,
|
|
2176
|
+
overallStatus,
|
|
2177
|
+
component: "TranslationDialog"
|
|
2178
|
+
}), /* @__PURE__ */ jsx(TranslationStatusPanel, {})) : STATES_IN_WHICH_TO_SHOW_ERROR.includes(overallStatus) ? (devLog.warn("Translation error state reached", {
|
|
2179
|
+
documentId: document._id,
|
|
2180
|
+
overallStatus,
|
|
2181
|
+
component: "TranslationDialog"
|
|
2182
|
+
}), /* @__PURE__ */ jsx("div", { children: "Error occurred" })) : (devLog.warn("Unhandled translation dialog state", {
|
|
2183
|
+
documentId: document._id,
|
|
2184
|
+
overallStatus,
|
|
2185
|
+
component: "TranslationDialog"
|
|
2186
|
+
}), /* @__PURE__ */ jsx("div", { children: "Unhandled overall state" }));
|
|
2187
|
+
}, getTranslateAction = (config) => ({ draft, published }) => {
|
|
2188
|
+
const document = draft ?? published, [isDialogOpen, setIsDialogOpen] = useState(!1);
|
|
2189
|
+
return devLog.debug("TranslateAction initialized", {
|
|
2190
|
+
documentId: document?._id,
|
|
2191
|
+
documentType: document?._type
|
|
2192
|
+
}), !document || !config.translatableSchemaTypes?.includes(document._type) ? (devLog.warn("Document not in translatable schema types", {
|
|
2193
|
+
documentType: document?._type,
|
|
2194
|
+
translatableTypes: config.translatableSchemaTypes
|
|
2195
|
+
}), null) : config ? {
|
|
2196
|
+
label: "Translate",
|
|
2197
|
+
icon: TranslateIcon,
|
|
2198
|
+
onHandle: () => {
|
|
2199
|
+
devLog.info("Translation dialog opened", {
|
|
2200
|
+
documentId: document._id,
|
|
2201
|
+
documentType: document._type,
|
|
2202
|
+
component: "TranslateAction"
|
|
2203
|
+
}), setIsDialogOpen(!0);
|
|
2204
|
+
},
|
|
2205
|
+
dialog: isDialogOpen && {
|
|
2206
|
+
type: "dialog",
|
|
2207
|
+
onClose: () => setIsDialogOpen(!1),
|
|
2208
|
+
header: "Translate Document",
|
|
2209
|
+
content: /* @__PURE__ */ jsx(TranslationContextProvider, { document, config, children: /* @__PURE__ */ jsx(TranslationDialog, { setIsDialogOpen }) })
|
|
2210
|
+
}
|
|
2211
|
+
} : (devLog.error("Missing config in TranslateAction"), null);
|
|
2212
|
+
}, aiTranslationPlugin = definePlugin((config) => ({
|
|
2213
|
+
name: "sanity-plugin-ai-translation",
|
|
2214
|
+
document: {
|
|
2215
|
+
actions: [getTranslateAction(config), getGiveTranslationFeedbackAction(config)]
|
|
2216
|
+
},
|
|
2217
|
+
schema: {
|
|
2218
|
+
types: [translationSettings, translationGlossaryTerm]
|
|
2219
|
+
}
|
|
2220
|
+
})), translationHistorySchema = translationHistory;
|
|
2221
|
+
export {
|
|
2222
|
+
aiTranslationPlugin,
|
|
2223
|
+
translationHistorySchema
|
|
2224
|
+
};
|
|
2225
|
+
//# sourceMappingURL=index.js.map
|