diff --git a/mobile/lib/core/models/recipe.dart b/mobile/lib/core/models/recipe.dart index cf12fc7..a4fad9e 100644 --- a/mobile/lib/core/models/recipe.dart +++ b/mobile/lib/core/models/recipe.dart @@ -79,6 +79,12 @@ class Recipe { }; } + String get calculatedTotalTime => RecipeTimeUtils.calculateTotalTime( + prepTime: prepTime, + cookTime: cookTime, + totalTime: totalTime, + ); + Recipe copyWith({ String? id, String? title, @@ -115,3 +121,90 @@ class Recipe { ); } } + +class RecipeTimeUtils { + static int parseDurationToMinutes(String? str) { + if (str == null) return 0; + final s = str.trim(); + if (s.isEmpty) return 0; + + // ISO 8601 Durations (e.g. PT1H30M, PT600S, PT45M, P0DT1H) + if (s.startsWith('P') || s.startsWith('p')) { + final isoRegex = RegExp(r'P(?:(\d+)D)?T?(?:(\d+(?:\.\d+)?)H)?(?:(\d+)M)?(?:(\d+)S)?', caseSensitive: false); + final match = isoRegex.firstMatch(s); + if (match != null) { + final days = int.tryParse(match.group(1) ?? '0') ?? 0; + final hours = double.tryParse(match.group(2) ?? '0') ?? 0.0; + final mins = int.tryParse(match.group(3) ?? '0') ?? 0; + final secs = int.tryParse(match.group(4) ?? '0') ?? 0; + final totalSecs = (days * 86400) + (hours * 3600).round() + (mins * 60) + secs; + if (totalSecs > 0) return (totalSecs / 60).round(); + } + } + + int totalMinutes = 0; + bool matched = false; + + // Hours: e.g. 1 hr, 1.5 hours, 2 hrs + final hrRegex = RegExp(r'(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|h\b)', caseSensitive: false); + final hrMatch = hrRegex.firstMatch(s); + if (hrMatch != null) { + final h = double.tryParse(hrMatch.group(1) ?? '0') ?? 0.0; + totalMinutes += (h * 60).round(); + matched = true; + } + + // Minutes: e.g. 15 mins, 10-14 mins, 45m + final minRegex = RegExp(r'(?:(\d+)\s*[-–to]+\s*)?(\d+)\s*(?:minutes?|mins?|m\b)', caseSensitive: false); + final minMatch = minRegex.firstMatch(s); + if (minMatch != null) { + final m = int.tryParse(minMatch.group(2) ?? '0') ?? 0; + totalMinutes += m; + matched = true; + } + + if (!matched && RegExp(r'^\d+$').hasMatch(s)) { + return int.tryParse(s) ?? 0; + } + + return totalMinutes; + } + + static String formatMinutes(int minutes) { + if (minutes <= 0) return ''; + final hrs = minutes ~/ 60; + final mins = minutes % 60; + if (hrs > 0 && mins > 0) { + return '$hrs hr${hrs > 1 ? 's' : ''} $mins min${mins > 1 ? 's' : ''}'; + } + if (hrs > 0) { + return '$hrs hr${hrs > 1 ? 's' : ''}'; + } + return '$mins min${mins > 1 ? 's' : ''}'; + } + + static String calculateTotalTime({String? prepTime, String? cookTime, String? totalTime}) { + if (totalTime != null && totalTime.trim().isNotEmpty) { + final t = totalTime.trim(); + if (t.startsWith('P') || t.startsWith('p')) { + final m = parseDurationToMinutes(t); + if (m > 0) return formatMinutes(m); + } + return t; + } + final prepM = parseDurationToMinutes(prepTime); + final cookM = parseDurationToMinutes(cookTime); + if (prepM > 0 || cookM > 0) { + return formatMinutes(prepM + cookM); + } + if (cookTime != null && cookTime.trim().isNotEmpty) { + final cM = parseDurationToMinutes(cookTime); + return cM > 0 ? formatMinutes(cM) : cookTime.trim(); + } + if (prepTime != null && prepTime.trim().isNotEmpty) { + final pM = parseDurationToMinutes(prepTime); + return pM > 0 ? formatMinutes(pM) : prepTime.trim(); + } + return ''; + } +} diff --git a/mobile/lib/features/library/recipes_screen.dart b/mobile/lib/features/library/recipes_screen.dart index 9255978..599950a 100644 --- a/mobile/lib/features/library/recipes_screen.dart +++ b/mobile/lib/features/library/recipes_screen.dart @@ -203,10 +203,10 @@ class _RecipesScreenState extends ConsumerState { const SizedBox(height: 8), Row( children: [ - if (recipe.cookTime != null && recipe.cookTime!.isNotEmpty) ...[ + if (recipe.calculatedTotalTime.isNotEmpty) ...[ const Icon(Icons.schedule_rounded, size: 14, color: AppTheme.primary), const SizedBox(width: 4), - Text(recipe.cookTime!, style: const TextStyle(fontSize: 12, color: AppTheme.textMuted)), + Text(recipe.calculatedTotalTime, style: const TextStyle(fontSize: 12, color: AppTheme.textMuted)), const SizedBox(width: 12), ], if (recipe.servings != null && recipe.servings!.isNotEmpty) ...[ @@ -262,6 +262,21 @@ class _RecipesScreenState extends ConsumerState { ), const SizedBox(height: 16), Text(recipe.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800)), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (recipe.servings != null && recipe.servings!.isNotEmpty) + _buildMetaChip(Icons.people_outline_rounded, '${recipe.servings} Servings', AppTheme.secondary), + if (recipe.prepTime != null && recipe.prepTime!.isNotEmpty) + _buildMetaChip(Icons.schedule_rounded, 'Prep: ${recipe.prepTime}', AppTheme.primary), + if (recipe.cookTime != null && recipe.cookTime!.isNotEmpty) + _buildMetaChip(Icons.local_fire_department_rounded, 'Cook: ${recipe.cookTime}', Colors.amber), + if (recipe.calculatedTotalTime.isNotEmpty) + _buildMetaChip(Icons.timer_rounded, 'Total: ${recipe.calculatedTotalTime}', Colors.tealAccent), + ], + ), const SizedBox(height: 16), SizedBox( width: double.infinity, @@ -307,4 +322,26 @@ class _RecipesScreenState extends ConsumerState { ), ); } + + Widget _buildMetaChip(IconData icon, String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 5), + Text( + label, + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color), + ), + ], + ), + ); + } } diff --git a/mobile/lib/features/share_receiver/quick_share_sheet.dart b/mobile/lib/features/share_receiver/quick_share_sheet.dart index 00eb406..9562d00 100644 --- a/mobile/lib/features/share_receiver/quick_share_sheet.dart +++ b/mobile/lib/features/share_receiver/quick_share_sheet.dart @@ -95,11 +95,20 @@ class _QuickShareSheetState extends ConsumerState { Future _handleSave() async { if (_scrapedRecipe == null) return; + final prepTime = _prepTimeController.text.trim(); + final cookTime = _cookTimeController.text.trim(); + final totalTime = RecipeTimeUtils.calculateTotalTime( + prepTime: prepTime, + cookTime: cookTime, + totalTime: _scrapedRecipe!.totalTime, + ); + final updated = _scrapedRecipe!.copyWith( title: _titleController.text.trim().isEmpty ? 'Untitled Recipe' : _titleController.text.trim(), servings: _servingsController.text.trim(), - prepTime: _prepTimeController.text.trim(), - cookTime: _cookTimeController.text.trim(), + prepTime: prepTime, + cookTime: cookTime, + totalTime: totalTime, sourceUrl: widget.url ?? _scrapedRecipe!.sourceUrl, ); diff --git a/public/app.js b/public/app.js index a6033f9..c6765c1 100644 --- a/public/app.js +++ b/public/app.js @@ -192,6 +192,7 @@ const elements = { tagServings: document.getElementById('tag-servings'), tagPrep: document.getElementById('tag-prep'), tagCook: document.getElementById('tag-cook'), + tagTotal: document.getElementById('tag-total'), tagSource: document.getElementById('tag-source'), recipeDetailDescription: document.getElementById('recipe-detail-description'), recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'), @@ -1324,6 +1325,8 @@ function renderLibrary() { `; } + const displayTotalTime = calculateTotalTime(recipe.prepTime, recipe.cookTime, recipe.totalTime); + card.innerHTML = `
${imgBlock} @@ -1336,7 +1339,7 @@ function renderLibrary() {
${recipe.servings ? ` ${recipe.servings} Servings` : ''} - ${recipe.totalTime ? ` ${recipe.totalTime}` : ''} + ${displayTotalTime ? ` ${displayTotalTime}` : ''}
`; @@ -1351,6 +1354,94 @@ function filterRecipes() { renderLibrary(); } +// ------------------------------------------------------------- +// DURATION & TOTAL TIME CALCULATION UTILITIES +// ------------------------------------------------------------- + +function parseDurationToMinutes(str) { + if (!str || typeof str !== 'string') return 0; + const s = str.trim(); + if (!s) return 0; + + // 1. ISO 8601 Durations (e.g. PT1H30M, PT600S, PT45M, P0DT1H, etc.) + if (s.startsWith('P') || s.startsWith('p')) { + const isoMatch = s.match(/P(?:(\d+)D)?T?(?:(\d+(?:\.\d+)?)H)?(?:(\d+)M)?(?:(\d+)S)?/i); + if (isoMatch) { + const days = parseInt(isoMatch[1] || '0', 10); + const hours = parseFloat(isoMatch[2] || '0'); + const mins = parseInt(isoMatch[3] || '0', 10); + const secs = parseInt(isoMatch[4] || '0', 10); + const totalSecs = (days * 86400) + Math.round(hours * 3600) + (mins * 60) + secs; + if (totalSecs > 0) return Math.round(totalSecs / 60); + } + } + + // 2. Natural language strings: '1 hr 30 mins', '45 mins', '1.5 hours', '10-14 mins' + let totalMinutes = 0; + let matched = false; + + // Hours: e.g. '1.5 hrs', '2 hours', '1 hr' + const hrMatch = s.match(/(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|h\b)/i); + if (hrMatch) { + totalMinutes += Math.round(parseFloat(hrMatch[1]) * 60); + matched = true; + } + + // Minutes: e.g. '30 mins', '10-14 mins', '45m' + const minMatch = s.match(/(?:(\d+)\s*[-–to]+\s*)?(\d+)\s*(?:minutes?|mins?|m\b)/i); + if (minMatch) { + const m = parseInt(minMatch[2], 10); + if (!isNaN(m)) { + totalMinutes += m; + matched = true; + } + } + + // Fallback: If string is pure digits, assume minutes + if (!matched && /^\d+$/.test(s)) { + return parseInt(s, 10); + } + + return totalMinutes; +} + +function formatMinutes(minutes) { + if (!minutes || minutes <= 0) return ''; + const hrs = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hrs > 0 && mins > 0) { + return `${hrs} hr${hrs > 1 ? 's' : ''} ${mins} min${mins > 1 ? 's' : ''}`; + } + if (hrs > 0) { + return `${hrs} hr${hrs > 1 ? 's' : ''}`; + } + return `${mins} min${mins > 1 ? 's' : ''}`; +} + +function calculateTotalTime(prepTime, cookTime, existingTotalTime) { + if (existingTotalTime && existingTotalTime.trim()) { + if (existingTotalTime.startsWith('P') || existingTotalTime.startsWith('p')) { + const mins = parseDurationToMinutes(existingTotalTime); + if (mins > 0) return formatMinutes(mins); + } + return existingTotalTime.trim(); + } + const prepM = parseDurationToMinutes(prepTime); + const cookM = parseDurationToMinutes(cookTime); + if (prepM > 0 || cookM > 0) { + return formatMinutes(prepM + cookM); + } + if (cookTime && cookTime.trim()) { + const cookMOnly = parseDurationToMinutes(cookTime); + return cookMOnly > 0 ? formatMinutes(cookMOnly) : cookTime.trim(); + } + if (prepTime && prepTime.trim()) { + const prepMOnly = parseDurationToMinutes(prepTime); + return prepMOnly > 0 ? formatMinutes(prepMOnly) : prepTime.trim(); + } + return ''; +} + // ------------------------------------------------------------- // DYNAMIC SERVINGS QUANTITY SCALER // ------------------------------------------------------------- @@ -1518,6 +1609,14 @@ function showRecipeDetails(recipe) { elements.tagCook.style.display = 'none'; } + const calculatedTotal = calculateTotalTime(recipe.prepTime, recipe.cookTime, recipe.totalTime); + if (calculatedTotal) { + elements.tagTotal.style.display = 'flex'; + elements.tagTotal.querySelector('.val').textContent = calculatedTotal; + } else { + elements.tagTotal.style.display = 'none'; + } + if (recipe.sourceUrl) { elements.tagSource.style.display = 'flex'; elements.tagSource.href = recipe.sourceUrl; diff --git a/public/index.html b/public/index.html index 38ae3e6..504fd79 100644 --- a/public/index.html +++ b/public/index.html @@ -803,6 +803,7 @@ 4 servings Prep: 15 mins Cook: 25 mins + Total: 40 mins diff --git a/public/share.html b/public/share.html index 9bc6550..3cfba6e 100644 --- a/public/share.html +++ b/public/share.html @@ -135,6 +135,7 @@ 4 servings Prep: 15 mins Cook: 25 mins + Total: 40 mins Original Source diff --git a/public/share.js b/public/share.js index 88ed459..9506397 100644 --- a/public/share.js +++ b/public/share.js @@ -18,6 +18,7 @@ const elements = { tagServings: document.getElementById('tag-servings'), tagPrep: document.getElementById('tag-prep'), tagCook: document.getElementById('tag-cook'), + tagTotal: document.getElementById('tag-total'), tagSource: document.getElementById('tag-source'), recipeDetailDescription: document.getElementById('recipe-detail-description'), recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'), @@ -160,6 +161,14 @@ function renderRecipePage(recipe) { } else { elements.tagCook.style.display = 'none'; } + + const calculatedTotal = calculateTotalTime(recipe.prepTime, recipe.cookTime, recipe.totalTime); + if (calculatedTotal) { + elements.tagTotal.style.display = 'flex'; + elements.tagTotal.querySelector('.val').textContent = calculatedTotal; + } else { + elements.tagTotal.style.display = 'none'; + } if (recipe.sourceUrl) { elements.tagSource.style.display = 'flex'; @@ -175,6 +184,86 @@ function renderRecipePage(recipe) { lucide.createIcons(); } +// DURATION & TOTAL TIME CALCULATION UTILITIES +function parseDurationToMinutes(str) { + if (!str || typeof str !== 'string') return 0; + const s = str.trim(); + if (!s) return 0; + + if (s.startsWith('P') || s.startsWith('p')) { + const isoMatch = s.match(/P(?:(\d+)D)?T?(?:(\d+(?:\.\d+)?)H)?(?:(\d+)M)?(?:(\d+)S)?/i); + if (isoMatch) { + const days = parseInt(isoMatch[1] || '0', 10); + const hours = parseFloat(isoMatch[2] || '0'); + const mins = parseInt(isoMatch[3] || '0', 10); + const secs = parseInt(isoMatch[4] || '0', 10); + const totalSecs = (days * 86400) + Math.round(hours * 3600) + (mins * 60) + secs; + if (totalSecs > 0) return Math.round(totalSecs / 60); + } + } + + let totalMinutes = 0; + let matched = false; + + const hrMatch = s.match(/(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|h\b)/i); + if (hrMatch) { + totalMinutes += Math.round(parseFloat(hrMatch[1]) * 60); + matched = true; + } + + const minMatch = s.match(/(?:(\d+)\s*[-–to]+\s*)?(\d+)\s*(?:minutes?|mins?|m\b)/i); + if (minMatch) { + const m = parseInt(minMatch[2], 10); + if (!isNaN(m)) { + totalMinutes += m; + matched = true; + } + } + + if (!matched && /^\d+$/.test(s)) { + return parseInt(s, 10); + } + + return totalMinutes; +} + +function formatMinutes(minutes) { + if (!minutes || minutes <= 0) return ''; + const hrs = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hrs > 0 && mins > 0) { + return `${hrs} hr${hrs > 1 ? 's' : ''} ${mins} min${mins > 1 ? 's' : ''}`; + } + if (hrs > 0) { + return `${hrs} hr${hrs > 1 ? 's' : ''}`; + } + return `${mins} min${mins > 1 ? 's' : ''}`; +} + +function calculateTotalTime(prepTime, cookTime, existingTotalTime) { + if (existingTotalTime && existingTotalTime.trim()) { + if (existingTotalTime.startsWith('P') || existingTotalTime.startsWith('p')) { + const mins = parseDurationToMinutes(existingTotalTime); + if (mins > 0) return formatMinutes(mins); + } + return existingTotalTime.trim(); + } + const prepM = parseDurationToMinutes(prepTime); + const cookM = parseDurationToMinutes(cookTime); + if (prepM > 0 || cookM > 0) { + return formatMinutes(prepM + cookM); + } + if (cookTime && cookTime.trim()) { + const cookMOnly = parseDurationToMinutes(cookTime); + return cookMOnly > 0 ? formatMinutes(cookMOnly) : cookTime.trim(); + } + if (prepTime && prepTime.trim()) { + const prepMOnly = parseDurationToMinutes(prepTime); + return prepMOnly > 0 ? formatMinutes(prepMOnly) : prepTime.trim(); + } + return ''; +} + // Ingredient Scaling Algorithms function adjustMultiplier(change) { const newMulti = currentMultiplier + change; diff --git a/server.js b/server.js index b23c8f2..de4dd04 100644 --- a/server.js +++ b/server.js @@ -400,18 +400,98 @@ const publicShareLimiter = rateLimit({ message: { error: 'Too many queries from this IP. Please try again in 15 minutes.' } }); -// Helper: Parse ISO 8601 Durations (e.g. PT1H30M to "1 hr 30 mins") +// Helper: Parse natural or ISO duration strings into total integer minutes +function parseDurationToMinutes(str) { + if (!str || typeof str !== 'string') return 0; + const s = str.trim(); + if (!s) return 0; + + // 1. ISO 8601 Durations (e.g. PT1H30M, PT600S, PT45M, P0DT1H, etc.) + if (s.startsWith('P') || s.startsWith('p')) { + const isoMatch = s.match(/P(?:(\d+)D)?T?(?:(\d+(?:\.\d+)?)H)?(?:(\d+)M)?(?:(\d+)S)?/i); + if (isoMatch) { + const days = parseInt(isoMatch[1] || '0', 10); + const hours = parseFloat(isoMatch[2] || '0'); + const mins = parseInt(isoMatch[3] || '0', 10); + const secs = parseInt(isoMatch[4] || '0', 10); + const totalSecs = (days * 86400) + Math.round(hours * 3600) + (mins * 60) + secs; + if (totalSecs > 0) return Math.round(totalSecs / 60); + } + } + + // 2. Natural language strings: '1 hr 30 mins', '45 mins', '1.5 hours', '10-14 mins' + let totalMinutes = 0; + let matched = false; + + // Hours: e.g. '1.5 hrs', '2 hours', '1 hr' + const hrMatch = s.match(/(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|h\b)/i); + if (hrMatch) { + totalMinutes += Math.round(parseFloat(hrMatch[1]) * 60); + matched = true; + } + + // Minutes: e.g. '30 mins', '10-14 mins', '45m' + const minMatch = s.match(/(?:(\d+)\s*[-–to]+\s*)?(\d+)\s*(?:minutes?|mins?|m\b)/i); + if (minMatch) { + const m = parseInt(minMatch[2], 10); + if (!isNaN(m)) { + totalMinutes += m; + matched = true; + } + } + + // Fallback: If string is pure digits, assume minutes + if (!matched && /^\d+$/.test(s)) { + return parseInt(s, 10); + } + + return totalMinutes; +} + +// Helper: Format minutes into human-readable duration +function formatMinutes(minutes) { + if (!minutes || minutes <= 0) return ''; + const hrs = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hrs > 0 && mins > 0) { + return `${hrs} hr${hrs > 1 ? 's' : ''} ${mins} min${mins > 1 ? 's' : ''}`; + } + if (hrs > 0) { + return `${hrs} hr${hrs > 1 ? 's' : ''}`; + } + return `${mins} min${mins > 1 ? 's' : ''}`; +} + +// Helper: Calculate Total Time from Prep Time and Cook Time if totalTime is missing +function calculateTotalTime(prepTime, cookTime, existingTotalTime) { + if (existingTotalTime && existingTotalTime.trim()) { + if (existingTotalTime.startsWith('P') || existingTotalTime.startsWith('p')) { + const mins = parseDurationToMinutes(existingTotalTime); + if (mins > 0) return formatMinutes(mins); + } + return existingTotalTime.trim(); + } + const prepM = parseDurationToMinutes(prepTime); + const cookM = parseDurationToMinutes(cookTime); + if (prepM > 0 || cookM > 0) { + return formatMinutes(prepM + cookM); + } + if (cookTime && cookTime.trim()) { + const cookMOnly = parseDurationToMinutes(cookTime); + return cookMOnly > 0 ? formatMinutes(cookMOnly) : cookTime.trim(); + } + if (prepTime && prepTime.trim()) { + const prepMOnly = parseDurationToMinutes(prepTime); + return prepMOnly > 0 ? formatMinutes(prepMOnly) : prepTime.trim(); + } + return ''; +} + +// Helper: Parse ISO 8601 Durations (e.g. PT1H30M to "1 hr 30 mins", PT600S to "10 mins") function parseISODuration(duration) { if (!duration || typeof duration !== 'string') return ''; - const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); - if (!match) return duration; - const hours = match[1] ? parseInt(match[1], 10) : 0; - const minutes = match[2] ? parseInt(match[2], 10) : 0; - - let result = ''; - if (hours > 0) result += `${hours} hr${hours > 1 ? 's' : ''} `; - if (minutes > 0) result += `${minutes} min${minutes > 1 ? 's' : ''}`; - return result.trim() || duration; + const mins = parseDurationToMinutes(duration); + return mins > 0 ? formatMinutes(mins) : duration; } // Helper: Extract recipe instructions from schema format @@ -553,8 +633,15 @@ function formatRecipe(row) { } } + const prepTime = row.prepTime ? (row.prepTime.startsWith('P') || row.prepTime.startsWith('p') ? parseISODuration(row.prepTime) : row.prepTime) : ''; + const cookTime = row.cookTime ? (row.cookTime.startsWith('P') || row.cookTime.startsWith('p') ? parseISODuration(row.cookTime) : row.cookTime) : ''; + const totalTime = calculateTotalTime(prepTime, cookTime, row.totalTime); + return { ...row, + prepTime, + cookTime, + totalTime, isPublic: row.isPublic === 1, images: JSON.parse(row.images || '[]'), ingredients: JSON.parse(row.ingredients || '[]'), @@ -1050,6 +1137,9 @@ app.post('/api/recipes', requireAuth, (req, res) => { const isPublic = req.body.isPublic !== false ? 1 : 0; const shareToken = isPublic === 1 ? crypto.randomBytes(16).toString('hex') : null; const newId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5); + const prepTime = req.body.prepTime ? (req.body.prepTime.startsWith('P') || req.body.prepTime.startsWith('p') ? parseISODuration(req.body.prepTime) : req.body.prepTime) : ''; + const cookTime = req.body.cookTime ? (req.body.cookTime.startsWith('P') || req.body.cookTime.startsWith('p') ? parseISODuration(req.body.cookTime) : req.body.cookTime) : ''; + const totalTime = calculateTotalTime(prepTime, cookTime, req.body.totalTime); try { db.prepare(` @@ -1065,9 +1155,9 @@ app.post('/api/recipes', requireAuth, (req, res) => { req.body.title || 'Untitled Recipe', req.body.description || '', req.body.servings || '', - req.body.prepTime || '', - req.body.cookTime || '', - req.body.totalTime || '', + prepTime, + cookTime, + totalTime, req.body.image || '', JSON.stringify(Array.isArray(req.body.images) ? req.body.images : []), JSON.stringify(Array.isArray(req.body.ingredients) ? req.body.ingredients : []), @@ -1108,6 +1198,12 @@ app.put('/api/recipes/:id', requireAuth, (req, res) => { } else if (isPublic === 0) { shareToken = null; // Revoke shareToken if it is made private } + + const prepTime = req.body.prepTime !== undefined ? req.body.prepTime : existing.prepTime; + const cookTime = req.body.cookTime !== undefined ? req.body.cookTime : existing.cookTime; + const totalTime = req.body.totalTime !== undefined && req.body.totalTime.trim() + ? req.body.totalTime.trim() + : calculateTotalTime(prepTime, cookTime, existing.totalTime); db.prepare(` UPDATE recipes @@ -1132,9 +1228,9 @@ app.put('/api/recipes/:id', requireAuth, (req, res) => { Array.isArray(req.body.ingredients) ? JSON.stringify(req.body.ingredients) : existing.ingredients, Array.isArray(req.body.instructions) ? JSON.stringify(req.body.instructions) : existing.instructions, req.body.servings !== undefined ? req.body.servings : existing.servings, - req.body.prepTime !== undefined ? req.body.prepTime : existing.prepTime, - req.body.cookTime !== undefined ? req.body.cookTime : existing.cookTime, - req.body.totalTime !== undefined ? req.body.totalTime : existing.totalTime, + prepTime, + cookTime, + totalTime, req.body.image !== undefined ? req.body.image : existing.image, Array.isArray(req.body.images) ? JSON.stringify(req.body.images) : existing.images, req.body.sourceUrl !== undefined ? req.body.sourceUrl : existing.sourceUrl, @@ -1256,7 +1352,7 @@ function parseRecipeFromHtml(html, url) { recipeData.prepTime = parseISODuration(schemaRecipe.prepTime || ''); recipeData.cookTime = parseISODuration(schemaRecipe.cookTime || ''); - recipeData.totalTime = parseISODuration(schemaRecipe.totalTime || ''); + recipeData.totalTime = calculateTotalTime(recipeData.prepTime, recipeData.cookTime, parseISODuration(schemaRecipe.totalTime || '')); const rawIngredients = schemaRecipe.recipeIngredient || schemaRecipe.recipeIngredients || []; if (typeof rawIngredients === 'string') {