From 4e2a7ff6240a110859840d0029a3d22e895b6a48 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sat, 27 Dec 2025 13:57:42 +0100 Subject: [PATCH] add translation support for portions, baking, cook times, and ingredient units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive translation support for previously untranslatable fields: - Portions (serving sizes) - Time fields (preparation, cooking, total_time) - Baking properties (temperature, length, mode) - Fermentation times (bulk, final) - Ingredient units (EL→tbsp, TL→tsp, etc.) Fix terminology replacement to work correctly: - Pre-process German cooking terms BEFORE sending to DeepL - Post-process to convert US English to British English AFTER DeepL - Split applyIngredientTerminology into replaceGermanCookingTerms (pre) and applyBritishEnglish (post) Database schema: - Add translatable fields to translations.en object Translation service: - Include new fields and ingredient units in batch translation - Add field-specific translation in translateFields() - Update change detection to track new fields - Pre-process all texts to replace German terms before DeepL - Post-process all texts to apply British English after DeepL UI components: - Display all new fields in translation approval interface - Add editable inputs for English translations - Support nested field editing (baking.temperature, fermentation.bulk, etc.) Fix changed fields detection: - Only show changed fields when editing existing translations - Don't show false warnings for first-time translations --- src/lib/components/TranslationApproval.svelte | 189 ++++++++++++++++++ src/models/Recipe.ts | 13 ++ .../edit/[name]/+page.svelte | 8 +- src/utils/translation.ts | 114 +++++++++-- 4 files changed, 304 insertions(+), 20 deletions(-) diff --git a/src/lib/components/TranslationApproval.svelte b/src/lib/components/TranslationApproval.svelte index 87c5cfa..0fb8440 100644 --- a/src/lib/components/TranslationApproval.svelte +++ b/src/lib/components/TranslationApproval.svelte @@ -64,6 +64,14 @@ // Special handling for tags (comma-separated string -> array) if (field === 'tags') { editableEnglish[field] = value.split(',').map((t: string) => t.trim()).filter((t: string) => t); + } + // Handle nested fields (e.g., baking.temperature, fermentation.bulk) + else if (field.includes('.')) { + const [parent, child] = field.split('.'); + if (!editableEnglish[parent]) { + editableEnglish[parent] = {}; + } + editableEnglish[parent][child] = value; } else { editableEnglish[field] = value; } @@ -500,6 +508,79 @@ button:disabled { {/if} + {#if germanData.portions} +
+ +
+ {/if} + + {#if germanData.preparation} +
+ +
+ {/if} + + {#if germanData.cooking} +
+ +
+ {/if} + + {#if germanData.total_time} +
+ +
+ {/if} + + {#if germanData.baking && (germanData.baking.temperature || germanData.baking.length || germanData.baking.mode)} +
+
Baking
+
+ {#if germanData.baking.temperature}Temperature: {germanData.baking.temperature}
{/if} + {#if germanData.baking.length}Time: {germanData.baking.length}
{/if} + {#if germanData.baking.mode}Mode: {germanData.baking.mode}{/if} +
+
+ {/if} + + {#if germanData.fermentation && (germanData.fermentation.bulk || germanData.fermentation.final)} +
+
Fermentation
+
+ {#if germanData.fermentation.bulk}Bulk: {germanData.fermentation.bulk}
{/if} + {#if germanData.fermentation.final}Final: {germanData.fermentation.final}{/if} +
+
+ {/if} + {#if germanData.ingredients && germanData.ingredients.length > 0}
Ingredients
@@ -636,6 +717,114 @@ button:disabled {
{/if} + {#if editableEnglish?.portions !== undefined} +
+ +
+ {/if} + + {#if editableEnglish?.preparation !== undefined} +
+ +
+ {/if} + + {#if editableEnglish?.cooking !== undefined} +
+ +
+ {/if} + + {#if editableEnglish?.total_time !== undefined} +
+ +
+ {/if} + + {#if editableEnglish?.baking} +
+
Baking (Editable)
+
+ + + +
+
+ {/if} + + {#if editableEnglish?.fermentation} +
+
Fermentation (Editable)
+
+ + +
+
+ {/if} + {#if editableEnglish?.ingredients && editableEnglish.ingredients.length > 0}
Ingredients (Editable)
diff --git a/src/models/Recipe.ts b/src/models/Recipe.ts index d4d57a8..69b2ae6 100644 --- a/src/models/Recipe.ts +++ b/src/models/Recipe.ts @@ -51,6 +51,19 @@ const RecipeSchema = new mongoose.Schema( note: {type: String}, category: {type: String}, tags: [String], + portions: {type: String}, + preparation: {type: String}, + cooking: {type: String}, + total_time: {type: String}, + baking: { + temperature: {type: String}, + length: {type: String}, + mode: {type: String}, + }, + fermentation: { + bulk: {type: String}, + final: {type: String}, + }, ingredients: [{ name: {type: String, default: ""}, list: [{ diff --git a/src/routes/[recipeLang=recipeLang]/edit/[name]/+page.svelte b/src/routes/[recipeLang=recipeLang]/edit/[name]/+page.svelte index 39574ad..f6bd689 100644 --- a/src/routes/[recipeLang=recipeLang]/edit/[name]/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/edit/[name]/+page.svelte @@ -127,7 +127,9 @@ const fieldsToCheck = [ 'name', 'description', 'preamble', 'addendum', - 'note', 'category', 'tags', 'ingredients', 'instructions' + 'note', 'category', 'tags', 'portions', 'preparation', + 'cooking', 'total_time', 'baking', 'fermentation', + 'ingredients', 'instructions' ]; for (const field of fieldsToCheck) { @@ -143,7 +145,9 @@ // Show translation workflow before submission function prepareSubmit() { - changedFields = detectChangedFields(); + // Only detect changed fields if there's an existing translation + // For first-time translations, changedFields should be empty + changedFields = translationData ? detectChangedFields() : []; showTranslationWorkflow = true; // Scroll to translation section diff --git a/src/utils/translation.ts b/src/utils/translation.ts index 01fb8ad..0048ecb 100644 --- a/src/utils/translation.ts +++ b/src/utils/translation.ts @@ -52,18 +52,17 @@ const US_TO_BRITISH_ENGLISH: Record = { }; /** - * Apply ingredient terminology replacements to translated text - * Handles both German terms that may have slipped through DeepL - * and US English to British English conversions - * @param text - The translated text to process - * @returns Text with terminology replacements applied + * Pre-process German text to replace cooking terminology BEFORE DeepL translation + * This ensures German abbreviations like EL, TL are correctly translated to tbsp, tsp + * @param text - The German text to pre-process + * @returns Text with German cooking terms replaced with English equivalents */ -function applyIngredientTerminology(text: string): string { +function replaceGermanCookingTerms(text: string): string { if (!text) return text; let result = text; - // First pass: Replace any remaining German terms with British English + // Replace German cooking terms with English equivalents // Using word boundaries to avoid partial matches Object.entries(INGREDIENT_TERMINOLOGY).forEach(([german, english]) => { // Case-insensitive replacement with word boundaries @@ -71,8 +70,21 @@ function applyIngredientTerminology(text: string): string { result = result.replace(regex, english); }); - // Second pass: Replace US English terms with British English - // More careful here to handle both whole words and phrases + return result; +} + +/** + * Post-process English text to convert US English to British English + * Applied AFTER DeepL translation + * @param text - The translated English text to process + * @returns Text with US English terms converted to British English + */ +function applyBritishEnglish(text: string): string { + if (!text) return text; + + let result = text; + + // Replace US English terms with British English Object.entries(US_TO_BRITISH_ENGLISH).forEach(([us, british]) => { // Case-insensitive replacement with word boundaries const regex = new RegExp(`\\b${us}\\b`, 'gi'); @@ -133,9 +145,12 @@ class DeepLTranslationService { } try { + // Pre-process: Replace German cooking terms BEFORE sending to DeepL + const preprocessedText = replaceGermanCookingTerms(text); + const params = new URLSearchParams({ auth_key: this.apiKey, - text: text, + text: preprocessedText, target_lang: targetLang, ...(preserveFormatting && { tag_handling: 'xml' }) }); @@ -156,8 +171,8 @@ class DeepLTranslationService { const data: DeepLResponse = await response.json(); const translatedText = data.translations[0]?.text || ''; - // Apply ingredient terminology replacements for British English - return applyIngredientTerminology(translatedText); + // Post-process: Convert US English to British English + return applyBritishEnglish(translatedText); } catch (error) { console.error('Translation error:', error); throw error; @@ -190,7 +205,9 @@ class DeepLTranslationService { texts.forEach((text, index) => { if (text && text.trim()) { nonEmptyIndices.push(index); - nonEmptyTexts.push(text); + // Pre-process: Replace German cooking terms BEFORE sending to DeepL + const preprocessed = replaceGermanCookingTerms(text); + nonEmptyTexts.push(preprocessed); } }); @@ -205,7 +222,7 @@ class DeepLTranslationService { target_lang: targetLang, }); - // Add each non-empty text as a separate 'text' parameter + // Add each preprocessed non-empty text as a separate 'text' parameter nonEmptyTexts.forEach(text => { params.append('text', text); }); @@ -226,8 +243,8 @@ class DeepLTranslationService { const data: DeepLResponse = await response.json(); const translatedTexts = data.translations.map(t => t.text); - // Apply ingredient terminology replacements for British English - const processedTexts = translatedTexts.map(text => applyIngredientTerminology(text)); + // Post-process: Convert US English to British English + const processedTexts = translatedTexts.map(text => applyBritishEnglish(text)); // Map translated texts back to original positions, preserving empty strings const result: string[] = []; @@ -267,8 +284,23 @@ class DeepLTranslationService { recipe.preamble || '', recipe.addendum || '', recipe.note || '', + recipe.portions || '', + recipe.preparation || '', + recipe.cooking || '', + recipe.total_time || '', ]; + // Add baking object fields + const baking = recipe.baking || {}; + textsToTranslate.push(baking.temperature || ''); + textsToTranslate.push(baking.length || ''); + textsToTranslate.push(baking.mode || ''); + + // Add fermentation object fields + const fermentation = recipe.fermentation || {}; + textsToTranslate.push(fermentation.bulk || ''); + textsToTranslate.push(fermentation.final || ''); + // Add tags const tags = recipe.tags || []; textsToTranslate.push(...tags); @@ -279,6 +311,7 @@ class DeepLTranslationService { textsToTranslate.push(ing.name || ''); (ing.list || []).forEach((item: any) => { textsToTranslate.push(item.name || ''); + textsToTranslate.push(item.unit || ''); // Translate units (EL→tbsp, TL→tsp) }); }); @@ -310,13 +343,26 @@ class DeepLTranslationService { preamble: translated[index++], addendum: translated[index++], note: translated[index++], + portions: translated[index++], + preparation: translated[index++], + cooking: translated[index++], + total_time: translated[index++], + baking: { + temperature: translated[index++], + length: translated[index++], + mode: translated[index++], + }, + fermentation: { + bulk: translated[index++], + final: 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, + unit: translated[index++], // Use translated unit (tbsp, tsp, etc.) amount: item.amount, })) })), @@ -356,6 +402,12 @@ class DeepLTranslationService { 'note', 'category', 'tags', + 'portions', + 'preparation', + 'cooking', + 'total_time', + 'baking', + 'fermentation', 'ingredients', 'instructions', ]; @@ -429,6 +481,31 @@ class DeepLTranslationService { case 'tags': result.tags = await this.translateBatch(recipe.tags || []); break; + case 'portions': + result.portions = await this.translateText(recipe.portions || ''); + break; + case 'preparation': + result.preparation = await this.translateText(recipe.preparation || ''); + break; + case 'cooking': + result.cooking = await this.translateText(recipe.cooking || ''); + break; + case 'total_time': + result.total_time = await this.translateText(recipe.total_time || ''); + break; + case 'baking': + result.baking = { + temperature: await this.translateText(recipe.baking?.temperature || ''), + length: await this.translateText(recipe.baking?.length || ''), + mode: await this.translateText(recipe.baking?.mode || ''), + }; + break; + case 'fermentation': + result.fermentation = { + bulk: await this.translateText(recipe.fermentation?.bulk || ''), + final: await this.translateText(recipe.fermentation?.final || ''), + }; + break; case 'ingredients': // This would be complex - for now, re-translate all ingredients result.ingredients = await this._translateIngredients(recipe.ingredients || []); @@ -455,6 +532,7 @@ class DeepLTranslationService { allTexts.push(ing.name || ''); (ing.list || []).forEach((item: any) => { allTexts.push(item.name || ''); + allTexts.push(item.unit || ''); // Translate units (EL→tbsp, TL→tsp) }); }); @@ -465,7 +543,7 @@ class DeepLTranslationService { name: translated[index++], list: (ing.list || []).map((item: any) => ({ name: translated[index++], - unit: item.unit, + unit: translated[index++], // Use translated unit amount: item.amount, })) }));