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:
2025-12-26 20:28:43 +01:00
parent 731adda897
commit 36a7fac39a
34 changed files with 3061 additions and 44 deletions

426
src/utils/translation.ts Normal file
View 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 };