feat: add ingredient scaling and enable wild mode scraping
Some checks failed
CI Pipeline / Lint Code (pull_request) Has been cancelled
CI Pipeline / Test API Package (pull_request) Has been cancelled
CI Pipeline / Test Web Package (pull_request) Has been cancelled
CI Pipeline / Test Shared Package (pull_request) Has been cancelled
CI Pipeline / Build All Packages (pull_request) Has been cancelled
CI Pipeline / Generate Coverage Report (pull_request) Has been cancelled
Docker Build & Deploy / Build Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Push Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Staging (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Production (pull_request) Has been cancelled
E2E Tests / End-to-End Tests (pull_request) Has been cancelled
E2E Tests / E2E Tests (Mobile) (pull_request) Has been cancelled
Security Scanning / NPM Audit (pull_request) Has been cancelled
Security Scanning / Dependency License Check (pull_request) Has been cancelled
Security Scanning / Code Quality Scan (pull_request) Has been cancelled
Security Scanning / Docker Image Security (pull_request) Has been cancelled
Security Scanning / Security Summary (pull_request) Has been cancelled
Some checks failed
CI Pipeline / Lint Code (pull_request) Has been cancelled
CI Pipeline / Test API Package (pull_request) Has been cancelled
CI Pipeline / Test Web Package (pull_request) Has been cancelled
CI Pipeline / Test Shared Package (pull_request) Has been cancelled
CI Pipeline / Build All Packages (pull_request) Has been cancelled
CI Pipeline / Generate Coverage Report (pull_request) Has been cancelled
Docker Build & Deploy / Build Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Push Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Staging (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Production (pull_request) Has been cancelled
E2E Tests / End-to-End Tests (pull_request) Has been cancelled
E2E Tests / E2E Tests (Mobile) (pull_request) Has been cancelled
Security Scanning / NPM Audit (pull_request) Has been cancelled
Security Scanning / Dependency License Check (pull_request) Has been cancelled
Security Scanning / Code Quality Scan (pull_request) Has been cancelled
Security Scanning / Docker Image Security (pull_request) Has been cancelled
Security Scanning / Security Summary (pull_request) Has been cancelled
## New Features ### 1. Ingredient Scaling Based on Servings - Added interactive servings control with +/- buttons on recipe detail page - Ingredients scale proportionally when servings are adjusted - Smart parsing handles fractions (1/2, ¼), mixed numbers (1 1/2), decimals, and ranges - Reset button to return to original servings - Non-scalable ingredients (e.g., "to taste") are detected and displayed unchanged **Files:** - NEW: packages/web/src/utils/ingredientParser.ts - Ingredient parsing and scaling logic - UPDATED: packages/web/src/pages/RecipeDetail.tsx - Added servings controls - UPDATED: packages/web/src/App.css - Styled servings control buttons ### 2. Wild Mode Scraping (Parity with Mealie) - Upgraded scraper to use `scrape_html()` with `supported_only=False` - Now works with ANY website that has recipe schema, not just 541+ supported sites - Matches Mealie's scraping capabilities - Successfully tested with littlespoonfarm.com and other previously unsupported sites **Changes:** - Switch from `scrape_me()` to `scrape_html()` with wild mode enabled - Added HTML fetching with proper user-agent headers - Now supports thousands of recipe websites beyond the officially supported list ## Testing ✅ Ingredient scaling tested with fractions, decimals, ranges ✅ Wild mode tested with littlespoonfarm.com (previously unsupported) ✅ Verified parity with Mealie's scraping performance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -136,6 +136,58 @@ nav a:hover {
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
color: #666;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.servings-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.servings-control button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background-color: #2d5016;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.servings-control button:hover:not(:disabled) {
|
||||
background-color: #3d6821;
|
||||
}
|
||||
|
||||
.servings-control button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.servings-control .reset-button {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: normal;
|
||||
background-color: #666;
|
||||
}
|
||||
|
||||
.servings-control .reset-button:hover {
|
||||
background-color: #777;
|
||||
}
|
||||
|
||||
.servings-control span {
|
||||
margin: 0 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ingredients, .instructions {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Recipe } from '@basil/shared';
|
||||
import { recipesApi } from '../services/api';
|
||||
import { scaleIngredientString } from '../utils/ingredientParser';
|
||||
|
||||
function RecipeDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -9,6 +10,7 @@ function RecipeDetail() {
|
||||
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentServings, setCurrentServings] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -20,7 +22,9 @@ function RecipeDetail() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await recipesApi.getById(recipeId);
|
||||
setRecipe(response.data || null);
|
||||
const loadedRecipe = response.data || null;
|
||||
setRecipe(loadedRecipe);
|
||||
setCurrentServings(loadedRecipe?.servings || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load recipe');
|
||||
@@ -30,6 +34,22 @@ function RecipeDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const incrementServings = () => {
|
||||
if (currentServings !== null) {
|
||||
setCurrentServings(currentServings + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const decrementServings = () => {
|
||||
if (currentServings !== null && currentServings > 1) {
|
||||
setCurrentServings(currentServings - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const resetServings = () => {
|
||||
setCurrentServings(recipe?.servings || null);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id || !confirm('Are you sure you want to delete this recipe?')) {
|
||||
return;
|
||||
@@ -73,7 +93,22 @@ function RecipeDetail() {
|
||||
{recipe.prepTime && <span>Prep: {recipe.prepTime} min</span>}
|
||||
{recipe.cookTime && <span>Cook: {recipe.cookTime} min</span>}
|
||||
{recipe.totalTime && <span>Total: {recipe.totalTime} min</span>}
|
||||
{recipe.servings && <span>Servings: {recipe.servings}</span>}
|
||||
{recipe.servings && currentServings !== null && (
|
||||
<div className="servings-control">
|
||||
<button onClick={decrementServings} disabled={currentServings <= 1}>
|
||||
−
|
||||
</button>
|
||||
<span>Servings: {currentServings}</span>
|
||||
<button onClick={incrementServings}>
|
||||
+
|
||||
</button>
|
||||
{currentServings !== recipe.servings && (
|
||||
<button onClick={resetServings} className="reset-button">
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recipe.sourceUrl && (
|
||||
@@ -89,14 +124,30 @@ function RecipeDetail() {
|
||||
<div className="ingredients">
|
||||
<h3>Ingredients</h3>
|
||||
<ul>
|
||||
{recipe.ingredients.map((ingredient, index) => (
|
||||
<li key={index}>
|
||||
{ingredient.amount && `${ingredient.amount} `}
|
||||
{ingredient.unit && `${ingredient.unit} `}
|
||||
{ingredient.name}
|
||||
{ingredient.notes && ` (${ingredient.notes})`}
|
||||
</li>
|
||||
))}
|
||||
{recipe.ingredients.map((ingredient, index) => {
|
||||
// Construct the full ingredient string
|
||||
let ingredientStr = '';
|
||||
if (ingredient.amount && ingredient.unit) {
|
||||
ingredientStr = `${ingredient.amount} ${ingredient.unit} ${ingredient.name}`;
|
||||
} else if (ingredient.amount) {
|
||||
ingredientStr = `${ingredient.amount} ${ingredient.name}`;
|
||||
} else {
|
||||
ingredientStr = ingredient.name;
|
||||
}
|
||||
|
||||
// Apply scaling if servings changed
|
||||
const displayStr =
|
||||
recipe.servings && currentServings && recipe.servings !== currentServings
|
||||
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
|
||||
: ingredientStr;
|
||||
|
||||
return (
|
||||
<li key={index}>
|
||||
{displayStr}
|
||||
{ingredient.notes && ` (${ingredient.notes})`}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
271
packages/web/src/utils/ingredientParser.ts
Normal file
271
packages/web/src/utils/ingredientParser.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Ingredient Parser Utility
|
||||
* Parses ingredient strings to extract amount, unit, and ingredient name
|
||||
* Handles fractions, decimals, ranges, and unicode characters
|
||||
*/
|
||||
|
||||
interface ParsedIngredient {
|
||||
amount: number | null;
|
||||
amountRange: { min: number; max: number } | null;
|
||||
unit: string;
|
||||
name: string;
|
||||
original: string;
|
||||
scalable: boolean;
|
||||
}
|
||||
|
||||
// Unicode fraction mappings
|
||||
const UNICODE_FRACTIONS: Record<string, number> = {
|
||||
'¼': 0.25,
|
||||
'½': 0.5,
|
||||
'¾': 0.75,
|
||||
'⅓': 0.333,
|
||||
'⅔': 0.667,
|
||||
'⅕': 0.2,
|
||||
'⅖': 0.4,
|
||||
'⅗': 0.6,
|
||||
'⅘': 0.8,
|
||||
'⅙': 0.167,
|
||||
'⅚': 0.833,
|
||||
'⅛': 0.125,
|
||||
'⅜': 0.375,
|
||||
'⅝': 0.625,
|
||||
'⅞': 0.875,
|
||||
};
|
||||
|
||||
// Common units (for parsing, not exhaustive)
|
||||
const UNITS = [
|
||||
// Volume
|
||||
'cup', 'cups', 'c',
|
||||
'tablespoon', 'tablespoons', 'tbsp', 'tbs', 'tb',
|
||||
'teaspoon', 'teaspoons', 'tsp', 'ts',
|
||||
'fluid ounce', 'fluid ounces', 'fl oz', 'fl. oz.',
|
||||
'milliliter', 'milliliters', 'ml',
|
||||
'liter', 'liters', 'l',
|
||||
'pint', 'pints', 'pt',
|
||||
'quart', 'quarts', 'qt',
|
||||
'gallon', 'gallons', 'gal',
|
||||
// Weight
|
||||
'pound', 'pounds', 'lb', 'lbs',
|
||||
'ounce', 'ounces', 'oz',
|
||||
'gram', 'grams', 'g',
|
||||
'kilogram', 'kilograms', 'kg',
|
||||
// Count/Other
|
||||
'piece', 'pieces',
|
||||
'slice', 'slices',
|
||||
'clove', 'cloves',
|
||||
'can', 'cans',
|
||||
'package', 'packages', 'pkg',
|
||||
'bunch', 'bunches',
|
||||
'pinch', 'pinches',
|
||||
'dash', 'dashes',
|
||||
'handful', 'handfuls',
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert fraction string to decimal
|
||||
*/
|
||||
function fractionToDecimal(fraction: string): number {
|
||||
const parts = fraction.split('/');
|
||||
if (parts.length !== 2) return 0;
|
||||
|
||||
const numerator = parseFloat(parts[0]);
|
||||
const denominator = parseFloat(parts[1]);
|
||||
|
||||
if (isNaN(numerator) || isNaN(denominator) || denominator === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse amount string (handles fractions, decimals, mixed numbers, ranges)
|
||||
*/
|
||||
function parseAmount(amountStr: string): { value: number | null; range: { min: number; max: number } | null } {
|
||||
amountStr = amountStr.trim();
|
||||
|
||||
// Replace unicode fractions
|
||||
for (const [unicode, decimal] of Object.entries(UNICODE_FRACTIONS)) {
|
||||
amountStr = amountStr.replace(unicode, ` ${decimal}`);
|
||||
}
|
||||
|
||||
// Handle ranges: "2-3", "1 to 2", "1-2"
|
||||
const rangeMatch = amountStr.match(/^(\d+(?:\.\d+)?)\s*(?:-|to)\s*(\d+(?:\.\d+)?)$/i);
|
||||
if (rangeMatch) {
|
||||
const min = parseFloat(rangeMatch[1]);
|
||||
const max = parseFloat(rangeMatch[2]);
|
||||
return { value: null, range: { min, max } };
|
||||
}
|
||||
|
||||
// Handle mixed numbers: "1 1/2", "2 3/4"
|
||||
const mixedMatch = amountStr.match(/^(\d+)\s+(\d+)\/(\d+)$/);
|
||||
if (mixedMatch) {
|
||||
const whole = parseFloat(mixedMatch[1]);
|
||||
const fraction = fractionToDecimal(`${mixedMatch[2]}/${mixedMatch[3]}`);
|
||||
return { value: whole + fraction, range: null };
|
||||
}
|
||||
|
||||
// Handle simple fractions: "1/2", "3/4"
|
||||
if (amountStr.includes('/')) {
|
||||
return { value: fractionToDecimal(amountStr), range: null };
|
||||
}
|
||||
|
||||
// Handle decimal numbers: "1.5", "2.25"
|
||||
const decimal = parseFloat(amountStr);
|
||||
if (!isNaN(decimal)) {
|
||||
return { value: decimal, range: null };
|
||||
}
|
||||
|
||||
return { value: null, range: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ingredient string into components
|
||||
*/
|
||||
export function parseIngredient(ingredientStr: string): ParsedIngredient {
|
||||
const original = ingredientStr;
|
||||
|
||||
// Check for non-scalable patterns
|
||||
const nonScalablePatterns = [
|
||||
/to taste/i,
|
||||
/as needed/i,
|
||||
/for (?:serving|garnish|dusting)/i,
|
||||
/optional/i,
|
||||
];
|
||||
|
||||
const isNonScalable = nonScalablePatterns.some(pattern => pattern.test(ingredientStr));
|
||||
|
||||
if (isNonScalable) {
|
||||
return {
|
||||
amount: null,
|
||||
amountRange: null,
|
||||
unit: '',
|
||||
name: ingredientStr,
|
||||
original,
|
||||
scalable: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract amount from beginning of string
|
||||
// Matches patterns like: "2", "1/2", "1 1/2", "2-3", "1.5", "¼"
|
||||
const amountPattern = /^([\d\u00BC-\u00BE\u2150-\u215E\s\/.-]+)/;
|
||||
const amountMatch = ingredientStr.match(amountPattern);
|
||||
|
||||
if (!amountMatch) {
|
||||
// No amount found - return as-is, not scalable
|
||||
return {
|
||||
amount: null,
|
||||
amountRange: null,
|
||||
unit: '',
|
||||
name: ingredientStr,
|
||||
original,
|
||||
scalable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const amountStr = amountMatch[1].trim();
|
||||
const { value: amount, range: amountRange } = parseAmount(amountStr);
|
||||
|
||||
// Remove amount from string
|
||||
let remaining = ingredientStr.substring(amountMatch[0].length).trim();
|
||||
|
||||
// Extract unit (check against known units)
|
||||
let unit = '';
|
||||
for (const possibleUnit of UNITS) {
|
||||
// Case-insensitive match at the beginning of remaining string
|
||||
const unitPattern = new RegExp(`^${possibleUnit}\\b`, 'i');
|
||||
if (unitPattern.test(remaining)) {
|
||||
unit = remaining.match(unitPattern)![0];
|
||||
remaining = remaining.substring(unit.length).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining text is the ingredient name
|
||||
const name = remaining;
|
||||
|
||||
return {
|
||||
amount,
|
||||
amountRange,
|
||||
unit,
|
||||
name,
|
||||
original,
|
||||
scalable: amount !== null || amountRange !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number as a fraction or mixed number for display
|
||||
*/
|
||||
export function formatAmount(amount: number): string {
|
||||
// If whole number, return as-is
|
||||
if (Number.isInteger(amount)) {
|
||||
return amount.toString();
|
||||
}
|
||||
|
||||
// Check if close to common fractions
|
||||
const commonFractions = [
|
||||
{ value: 0.125, display: '⅛' },
|
||||
{ value: 0.25, display: '¼' },
|
||||
{ value: 0.333, display: '⅓' },
|
||||
{ value: 0.375, display: '⅜' },
|
||||
{ value: 0.5, display: '½' },
|
||||
{ value: 0.625, display: '⅝' },
|
||||
{ value: 0.667, display: '⅔' },
|
||||
{ value: 0.75, display: '¾' },
|
||||
{ value: 0.875, display: '⅞' },
|
||||
];
|
||||
|
||||
const wholePart = Math.floor(amount);
|
||||
const fractionalPart = amount - wholePart;
|
||||
|
||||
// Find closest fraction
|
||||
for (const frac of commonFractions) {
|
||||
if (Math.abs(fractionalPart - frac.value) < 0.01) {
|
||||
if (wholePart > 0) {
|
||||
return `${wholePart} ${frac.display}`;
|
||||
}
|
||||
return frac.display;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, round to 1 decimal place
|
||||
return amount.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale an ingredient by a ratio
|
||||
*/
|
||||
export function scaleIngredient(parsed: ParsedIngredient, ratio: number): string {
|
||||
if (!parsed.scalable || ratio === 1) {
|
||||
return parsed.original;
|
||||
}
|
||||
|
||||
let scaledAmountStr = '';
|
||||
|
||||
if (parsed.amount !== null) {
|
||||
const scaledAmount = parsed.amount * ratio;
|
||||
scaledAmountStr = formatAmount(scaledAmount);
|
||||
} else if (parsed.amountRange !== null) {
|
||||
const scaledMin = parsed.amountRange.min * ratio;
|
||||
const scaledMax = parsed.amountRange.max * ratio;
|
||||
scaledAmountStr = `${formatAmount(scaledMin)}-${formatAmount(scaledMax)}`;
|
||||
}
|
||||
|
||||
// Reconstruct the ingredient string
|
||||
const parts = [scaledAmountStr, parsed.unit, parsed.name].filter(p => p).join(' ');
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to scale an ingredient string
|
||||
*/
|
||||
export function scaleIngredientString(ingredientStr: string, originalServings: number, newServings: number): string {
|
||||
if (originalServings === 0 || newServings === originalServings) {
|
||||
return ingredientStr;
|
||||
}
|
||||
|
||||
const ratio = newServings / originalServings;
|
||||
const parsed = parseIngredient(ingredientStr);
|
||||
return scaleIngredient(parsed, ratio);
|
||||
}
|
||||
Reference in New Issue
Block a user