- 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
427 lines
11 KiB
TypeScript
427 lines
11 KiB
TypeScript
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 };
|