add English translation support for recipes with DeepL integration
- Add embedded translations schema to Recipe model with English support - Create DeepL translation service with batch translation and change detection - Build translation approval UI with side-by-side editing for all recipe fields - Integrate translation workflow into add/edit pages with field comparison - Create complete English recipe routes at /recipes/* mirroring German structure - Add language switcher component with hreflang SEO tags - Support image loading from German short_name for English recipes - Add English API endpoints for all recipe filters (category, tag, icon, season) - Include layout with English navigation header for all recipe subroutes
This commit is contained in:
426
src/utils/translation.ts
Normal file
426
src/utils/translation.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { DEEPL_API_KEY, DEEPL_API_URL } from '$env/static/private';
|
||||
|
||||
// Category translation dictionary for consistency
|
||||
const CATEGORY_TRANSLATIONS: Record<string, string> = {
|
||||
"Brot": "Bread",
|
||||
"Kuchen": "Cake",
|
||||
"Suppe": "Soup",
|
||||
"Salat": "Salad",
|
||||
"Hauptgericht": "Main Course",
|
||||
"Beilage": "Side Dish",
|
||||
"Dessert": "Dessert",
|
||||
"Getränk": "Beverage",
|
||||
"Frühstück": "Breakfast",
|
||||
"Snack": "Snack"
|
||||
};
|
||||
|
||||
interface DeepLResponse {
|
||||
translations: Array<{
|
||||
detected_source_language: string;
|
||||
text: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TranslationResult {
|
||||
text: string;
|
||||
detectedSourceLang: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepL Translation Service
|
||||
* Handles all translation operations using the DeepL API
|
||||
*/
|
||||
class DeepLTranslationService {
|
||||
private apiKey: string;
|
||||
private apiUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.apiKey = DEEPL_API_KEY || '';
|
||||
this.apiUrl = DEEPL_API_URL || 'https://api-free.deepl.com/v2/translate';
|
||||
|
||||
if (!this.apiKey) {
|
||||
console.warn('DEEPL_API_KEY not found in environment variables');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a single text string
|
||||
* @param text - The text to translate
|
||||
* @param targetLang - Target language code (default: 'EN')
|
||||
* @param preserveFormatting - Whether to preserve HTML/formatting
|
||||
* @returns Translated text
|
||||
*/
|
||||
async translateText(
|
||||
text: string | null | undefined,
|
||||
targetLang: string = 'EN',
|
||||
preserveFormatting: boolean = false
|
||||
): Promise<string> {
|
||||
// Return empty string for null, undefined, or empty strings
|
||||
if (!text || text.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error('DeepL API key not configured');
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
auth_key: this.apiKey,
|
||||
text: text,
|
||||
target_lang: targetLang,
|
||||
...(preserveFormatting && { tag_handling: 'xml' })
|
||||
});
|
||||
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`DeepL API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data: DeepLResponse = await response.json();
|
||||
return data.translations[0]?.text || '';
|
||||
} catch (error) {
|
||||
console.error('Translation error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate multiple texts in a single batch request
|
||||
* More efficient than individual calls
|
||||
* @param texts - Array of texts to translate
|
||||
* @param targetLang - Target language code
|
||||
* @returns Array of translated texts (preserves empty strings in original positions)
|
||||
*/
|
||||
async translateBatch(
|
||||
texts: string[],
|
||||
targetLang: string = 'EN'
|
||||
): Promise<string[]> {
|
||||
if (!texts.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error('DeepL API key not configured');
|
||||
}
|
||||
|
||||
// Track which indices have non-empty text
|
||||
const nonEmptyIndices: number[] = [];
|
||||
const nonEmptyTexts: string[] = [];
|
||||
|
||||
texts.forEach((text, index) => {
|
||||
if (text && text.trim()) {
|
||||
nonEmptyIndices.push(index);
|
||||
nonEmptyTexts.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
// If all texts are empty, return array of empty strings
|
||||
if (nonEmptyTexts.length === 0) {
|
||||
return texts.map(() => '');
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
auth_key: this.apiKey,
|
||||
target_lang: targetLang,
|
||||
});
|
||||
|
||||
// Add each non-empty text as a separate 'text' parameter
|
||||
nonEmptyTexts.forEach(text => {
|
||||
params.append('text', text);
|
||||
});
|
||||
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`DeepL API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data: DeepLResponse = await response.json();
|
||||
const translatedTexts = data.translations.map(t => t.text);
|
||||
|
||||
// Map translated texts back to original positions, preserving empty strings
|
||||
const result: string[] = [];
|
||||
let translatedIndex = 0;
|
||||
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
if (nonEmptyIndices.includes(i)) {
|
||||
result.push(translatedTexts[translatedIndex]);
|
||||
translatedIndex++;
|
||||
} else {
|
||||
result.push(''); // Keep empty string
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Batch translation error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a complete recipe object
|
||||
* @param recipe - The recipe object to translate
|
||||
* @returns Translated recipe data
|
||||
*/
|
||||
async translateRecipe(recipe: any): Promise<any> {
|
||||
try {
|
||||
// Translate category using dictionary first, fallback to DeepL
|
||||
const translatedCategory = CATEGORY_TRANSLATIONS[recipe.category]
|
||||
|| await this.translateText(recipe.category);
|
||||
|
||||
// Collect all texts to translate in batch
|
||||
const textsToTranslate: string[] = [
|
||||
recipe.name,
|
||||
recipe.description,
|
||||
recipe.preamble || '',
|
||||
recipe.addendum || '',
|
||||
recipe.note || '',
|
||||
];
|
||||
|
||||
// Add tags
|
||||
const tags = recipe.tags || [];
|
||||
textsToTranslate.push(...tags);
|
||||
|
||||
// Add ingredient names and list items
|
||||
const ingredients = recipe.ingredients || [];
|
||||
ingredients.forEach((ing: any) => {
|
||||
textsToTranslate.push(ing.name || '');
|
||||
(ing.list || []).forEach((item: any) => {
|
||||
textsToTranslate.push(item.name || '');
|
||||
});
|
||||
});
|
||||
|
||||
// Add instruction names and steps
|
||||
const instructions = recipe.instructions || [];
|
||||
instructions.forEach((inst: any) => {
|
||||
textsToTranslate.push(inst.name || '');
|
||||
(inst.steps || []).forEach((step: string) => {
|
||||
textsToTranslate.push(step || '');
|
||||
});
|
||||
});
|
||||
|
||||
// Add image alt and caption texts
|
||||
const images = recipe.images || [];
|
||||
images.forEach((img: any) => {
|
||||
textsToTranslate.push(img.alt || '');
|
||||
textsToTranslate.push(img.caption || '');
|
||||
});
|
||||
|
||||
// Batch translate all texts
|
||||
const translated = await this.translateBatch(textsToTranslate);
|
||||
|
||||
// Reconstruct translated recipe
|
||||
let index = 0;
|
||||
const translatedRecipe = {
|
||||
short_name: this.generateEnglishSlug(recipe.name),
|
||||
name: translated[index++],
|
||||
description: translated[index++],
|
||||
preamble: translated[index++],
|
||||
addendum: translated[index++],
|
||||
note: translated[index++],
|
||||
category: translatedCategory,
|
||||
tags: tags.map(() => translated[index++]),
|
||||
ingredients: ingredients.map((ing: any) => ({
|
||||
name: translated[index++],
|
||||
list: (ing.list || []).map((item: any) => ({
|
||||
name: translated[index++],
|
||||
unit: item.unit,
|
||||
amount: item.amount,
|
||||
}))
|
||||
})),
|
||||
instructions: instructions.map((inst: any) => ({
|
||||
name: translated[index++],
|
||||
steps: (inst.steps || []).map(() => translated[index++])
|
||||
})),
|
||||
images: images.map((img: any) => ({
|
||||
alt: translated[index++],
|
||||
caption: translated[index++],
|
||||
})),
|
||||
translationStatus: 'pending' as const,
|
||||
lastTranslated: new Date(),
|
||||
changedFields: [],
|
||||
};
|
||||
|
||||
return translatedRecipe;
|
||||
} catch (error) {
|
||||
console.error('Recipe translation error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which fields have changed between old and new recipe
|
||||
* Used to determine what needs re-translation
|
||||
* @param oldRecipe - Original recipe
|
||||
* @param newRecipe - Modified recipe
|
||||
* @returns Array of changed field names
|
||||
*/
|
||||
detectChangedFields(oldRecipe: any, newRecipe: any): string[] {
|
||||
const fieldsToCheck = [
|
||||
'name',
|
||||
'description',
|
||||
'preamble',
|
||||
'addendum',
|
||||
'note',
|
||||
'category',
|
||||
'tags',
|
||||
'ingredients',
|
||||
'instructions',
|
||||
];
|
||||
|
||||
const changed: string[] = [];
|
||||
|
||||
for (const field of fieldsToCheck) {
|
||||
const oldValue = JSON.stringify(oldRecipe[field] || '');
|
||||
const newValue = JSON.stringify(newRecipe[field] || '');
|
||||
|
||||
if (oldValue !== newValue) {
|
||||
changed.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL-friendly English slug from German name
|
||||
* Ensures uniqueness by checking against existing recipes
|
||||
* @param germanName - The German recipe name
|
||||
* @returns URL-safe English slug
|
||||
*/
|
||||
generateEnglishSlug(germanName: string): string {
|
||||
// This will be translated name, so we just need to slugify it
|
||||
const slug = germanName
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate only specific fields of a recipe
|
||||
* Used when only some fields have changed
|
||||
* @param recipe - The recipe object
|
||||
* @param fields - Array of field names to translate
|
||||
* @returns Partial translated recipe with only specified fields
|
||||
*/
|
||||
async translateFields(recipe: any, fields: string[]): Promise<any> {
|
||||
const result: any = {};
|
||||
|
||||
for (const field of fields) {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
result.name = await this.translateText(recipe.name);
|
||||
result.short_name = this.generateEnglishSlug(result.name);
|
||||
break;
|
||||
case 'description':
|
||||
result.description = await this.translateText(recipe.description);
|
||||
break;
|
||||
case 'preamble':
|
||||
result.preamble = await this.translateText(recipe.preamble || '', 'EN', true);
|
||||
break;
|
||||
case 'addendum':
|
||||
result.addendum = await this.translateText(recipe.addendum || '', 'EN', true);
|
||||
break;
|
||||
case 'note':
|
||||
result.note = await this.translateText(recipe.note || '');
|
||||
break;
|
||||
case 'category':
|
||||
result.category = CATEGORY_TRANSLATIONS[recipe.category]
|
||||
|| await this.translateText(recipe.category);
|
||||
break;
|
||||
case 'tags':
|
||||
result.tags = await this.translateBatch(recipe.tags || []);
|
||||
break;
|
||||
case 'ingredients':
|
||||
// This would be complex - for now, re-translate all ingredients
|
||||
result.ingredients = await this._translateIngredients(recipe.ingredients || []);
|
||||
break;
|
||||
case 'instructions':
|
||||
// This would be complex - for now, re-translate all instructions
|
||||
result.instructions = await this._translateInstructions(recipe.instructions || []);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result.lastTranslated = new Date();
|
||||
result.changedFields = [];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Translate ingredients array
|
||||
*/
|
||||
private async _translateIngredients(ingredients: any[]): Promise<any[]> {
|
||||
const allTexts: string[] = [];
|
||||
ingredients.forEach(ing => {
|
||||
allTexts.push(ing.name || '');
|
||||
(ing.list || []).forEach((item: any) => {
|
||||
allTexts.push(item.name || '');
|
||||
});
|
||||
});
|
||||
|
||||
const translated = await this.translateBatch(allTexts);
|
||||
let index = 0;
|
||||
|
||||
return ingredients.map(ing => ({
|
||||
name: translated[index++],
|
||||
list: (ing.list || []).map((item: any) => ({
|
||||
name: translated[index++],
|
||||
unit: item.unit,
|
||||
amount: item.amount,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Translate instructions array
|
||||
*/
|
||||
private async _translateInstructions(instructions: any[]): Promise<any[]> {
|
||||
const allTexts: string[] = [];
|
||||
instructions.forEach(inst => {
|
||||
allTexts.push(inst.name || '');
|
||||
(inst.steps || []).forEach((step: string) => {
|
||||
allTexts.push(step || '');
|
||||
});
|
||||
});
|
||||
|
||||
const translated = await this.translateBatch(allTexts);
|
||||
let index = 0;
|
||||
|
||||
return instructions.map(inst => ({
|
||||
name: translated[index++],
|
||||
steps: (inst.steps || []).map(() => translated[index++])
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const translationService = new DeepLTranslationService();
|
||||
|
||||
// Export class for testing
|
||||
export { DeepLTranslationService };
|
||||
Reference in New Issue
Block a user