add translation support for portions, baking, cook times, and ingredient units

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
This commit is contained in:
2025-12-27 13:57:42 +01:00
parent 0d01e595ea
commit 4e2a7ff624
4 changed files with 304 additions and 20 deletions

View File

@@ -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 {
</div>
{/if}
{#if germanData.portions}
<div class="field-group">
<TranslationFieldComparison
label="Portions"
germanValue={germanData.portions}
englishValue={editableEnglish?.portions || ''}
fieldName="portions"
readonly={true}
on:change={handleFieldChange}
/>
</div>
{/if}
{#if germanData.preparation}
<div class="field-group">
<TranslationFieldComparison
label="Preparation Time"
germanValue={germanData.preparation}
englishValue={editableEnglish?.preparation || ''}
fieldName="preparation"
readonly={true}
on:change={handleFieldChange}
/>
</div>
{/if}
{#if germanData.cooking}
<div class="field-group">
<TranslationFieldComparison
label="Cooking Time"
germanValue={germanData.cooking}
englishValue={editableEnglish?.cooking || ''}
fieldName="cooking"
readonly={true}
on:change={handleFieldChange}
/>
</div>
{/if}
{#if germanData.total_time}
<div class="field-group">
<TranslationFieldComparison
label="Total Time"
germanValue={germanData.total_time}
englishValue={editableEnglish?.total_time || ''}
fieldName="total_time"
readonly={true}
on:change={handleFieldChange}
/>
</div>
{/if}
{#if germanData.baking && (germanData.baking.temperature || germanData.baking.length || germanData.baking.mode)}
<div class="field-group">
<div class="field-label">Baking</div>
<div class="field-value readonly readonly-text">
{#if germanData.baking.temperature}Temperature: {germanData.baking.temperature}<br>{/if}
{#if germanData.baking.length}Time: {germanData.baking.length}<br>{/if}
{#if germanData.baking.mode}Mode: {germanData.baking.mode}{/if}
</div>
</div>
{/if}
{#if germanData.fermentation && (germanData.fermentation.bulk || germanData.fermentation.final)}
<div class="field-group">
<div class="field-label">Fermentation</div>
<div class="field-value readonly readonly-text">
{#if germanData.fermentation.bulk}Bulk: {germanData.fermentation.bulk}<br>{/if}
{#if germanData.fermentation.final}Final: {germanData.fermentation.final}{/if}
</div>
</div>
{/if}
{#if germanData.ingredients && germanData.ingredients.length > 0}
<div class="field-group">
<div class="field-label">Ingredients</div>
@@ -636,6 +717,114 @@ button:disabled {
</div>
{/if}
{#if editableEnglish?.portions !== undefined}
<div class="field-group">
<TranslationFieldComparison
label="Portions"
germanValue={germanData.portions || ''}
englishValue={editableEnglish.portions}
fieldName="portions"
readonly={false}
on:change={handleFieldChange}
/>
</div>
{/if}
{#if editableEnglish?.preparation !== undefined}
<div class="field-group">
<TranslationFieldComparison
label="Preparation Time"
germanValue={germanData.preparation || ''}
englishValue={editableEnglish.preparation}
fieldName="preparation"
readonly={false}
on:change={handleFieldChange}
/>
</div>
{/if}
{#if editableEnglish?.cooking !== undefined}
<div class="field-group">
<TranslationFieldComparison
label="Cooking Time"
germanValue={germanData.cooking || ''}
englishValue={editableEnglish.cooking}
fieldName="cooking"
readonly={false}
on:change={handleFieldChange}
/>
</div>
{/if}
{#if editableEnglish?.total_time !== undefined}
<div class="field-group">
<TranslationFieldComparison
label="Total Time"
germanValue={germanData.total_time || ''}
englishValue={editableEnglish.total_time}
fieldName="total_time"
readonly={false}
on:change={handleFieldChange}
/>
</div>
{/if}
{#if editableEnglish?.baking}
<div class="field-group">
<div class="field-label">Baking (Editable)</div>
<div class="field-value">
<TranslationFieldComparison
label="Temperature"
germanValue={germanData.baking?.temperature || ''}
englishValue={editableEnglish.baking.temperature}
fieldName="baking.temperature"
readonly={false}
on:change={handleFieldChange}
/>
<TranslationFieldComparison
label="Time"
germanValue={germanData.baking?.length || ''}
englishValue={editableEnglish.baking.length}
fieldName="baking.length"
readonly={false}
on:change={handleFieldChange}
/>
<TranslationFieldComparison
label="Mode"
germanValue={germanData.baking?.mode || ''}
englishValue={editableEnglish.baking.mode}
fieldName="baking.mode"
readonly={false}
on:change={handleFieldChange}
/>
</div>
</div>
{/if}
{#if editableEnglish?.fermentation}
<div class="field-group">
<div class="field-label">Fermentation (Editable)</div>
<div class="field-value">
<TranslationFieldComparison
label="Bulk"
germanValue={germanData.fermentation?.bulk || ''}
englishValue={editableEnglish.fermentation.bulk}
fieldName="fermentation.bulk"
readonly={false}
on:change={handleFieldChange}
/>
<TranslationFieldComparison
label="Final"
germanValue={germanData.fermentation?.final || ''}
englishValue={editableEnglish.fermentation.final}
fieldName="fermentation.final"
readonly={false}
on:change={handleFieldChange}
/>
</div>
</div>
{/if}
{#if editableEnglish?.ingredients && editableEnglish.ingredients.length > 0}
<div class="field-group">
<div class="field-label">Ingredients (Editable)</div>

View File

@@ -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: [{

View File

@@ -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

View File

@@ -52,18 +52,17 @@ const US_TO_BRITISH_ENGLISH: Record<string, string> = {
};
/**
* 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,
}))
}));