mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
Harmonize recipe time calculations and display across web and mobile
This commit is contained in:
parent
f4c2df6a05
commit
86442f62e0
8 changed files with 447 additions and 22 deletions
|
|
@ -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 '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,10 +203,10 @@ class _RecipesScreenState extends ConsumerState<RecipesScreen> {
|
|||
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<RecipesScreen> {
|
|||
),
|
||||
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<RecipesScreen> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,11 +95,20 @@ class _QuickShareSheetState extends ConsumerState<QuickShareSheet> {
|
|||
Future<void> _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,
|
||||
);
|
||||
|
||||
|
|
|
|||
101
public/app.js
101
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 = `
|
||||
<div class="recipe-card-img-wrapper">
|
||||
${imgBlock}
|
||||
|
|
@ -1336,7 +1339,7 @@ function renderLibrary() {
|
|||
|
||||
<div class="recipe-card-meta">
|
||||
${recipe.servings ? `<span><i data-lucide="users"></i> ${recipe.servings} Servings</span>` : ''}
|
||||
${recipe.totalTime ? `<span><i data-lucide="clock"></i> ${recipe.totalTime}</span>` : ''}
|
||||
${displayTotalTime ? `<span><i data-lucide="clock"></i> ${displayTotalTime}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -803,6 +803,7 @@
|
|||
<span id="tag-servings"><i data-lucide="users"></i> <span class="val">4 servings</span></span>
|
||||
<span id="tag-prep"><i data-lucide="clock"></i> Prep: <span class="val">15 mins</span></span>
|
||||
<span id="tag-cook"><i data-lucide="flame"></i> Cook: <span class="val">25 mins</span></span>
|
||||
<span id="tag-total"><i data-lucide="timer"></i> Total: <span class="val">40 mins</span></span>
|
||||
<a id="tag-source" href="#" target="_blank" rel="noopener noreferrer" style="display: none;">
|
||||
<i data-lucide="external-link"></i> <span class="val">Original Source</span>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@
|
|||
<span id="tag-servings"><i data-lucide="users"></i> <span class="val">4 servings</span></span>
|
||||
<span id="tag-prep"><i data-lucide="clock"></i> Prep: <span class="val">15 mins</span></span>
|
||||
<span id="tag-cook"><i data-lucide="flame"></i> Cook: <span class="val">25 mins</span></span>
|
||||
<span id="tag-total"><i data-lucide="timer"></i> Total: <span class="val">40 mins</span></span>
|
||||
<a id="tag-source" href="#" target="_blank" rel="noopener noreferrer">
|
||||
<i data-lucide="external-link"></i> <span class="val">Original Source</span>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
130
server.js
130
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') {
|
||||
|
|
|
|||
Loading…
Reference in a new issue