feat: add CI/CD pipeline, backup system, and deployment automation
Some checks failed
CI/CD Pipeline / Run Tests (pull_request) Has been cancelled
CI/CD Pipeline / Code Quality (pull_request) Has been cancelled
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
Docker Build & Deploy / Build Docker Images (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
CI/CD Pipeline / Build and Push Docker Images (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 / 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
Security Scanning / Security Summary (pull_request) Has been cancelled

## Summary
- Add complete CI/CD pipeline with Gitea Actions for automated testing, building, and deployment
- Implement backup and restore system with full database and file backup to ZIP
- Add deployment automation with webhook receiver and systemd service
- Enhance recipe editing UI with improved ingredient parsing and cooking mode features
- Add comprehensive documentation for CI/CD, deployment, and backup features

## CI/CD Pipeline
- New workflow in .gitea/workflows/ci-cd.yml with test, build, and deploy stages
- Automated Docker image building and pushing to registry
- Webhook-triggered deployments to production servers

## Backup & Restore
- New backup service with ZIP creation including database dump and uploads
- REST API endpoints for create, list, download, restore, and delete operations
- Configurable backup path via BACKUP_PATH environment variable

## Deployment
- Automated deployment scripts (deploy.sh, manual-deploy.sh)
- Webhook receiver with systemd service for deployment triggers
- Environment configuration template (.env.deploy.example)

## Documentation
- docs/CI-CD-SETUP.md - Complete CI/CD pipeline setup guide
- docs/DEPLOYMENT-QUICK-START.md - Quick deployment reference
- docs/BACKUP.md - Backup and restore documentation
- docs/REMOTE_DATABASE.md - Remote database configuration guide
- scripts/README.md - Deployment scripts documentation

## Web Improvements
- Enhanced ingredient parser with better unit and quantity detection
- Improved recipe editing interface with unified edit experience
- Better cooking mode functionality
- Updated dependencies in package.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-08 05:04:39 +00:00
parent 35a3088c30
commit d1156833a2
24 changed files with 3543 additions and 229 deletions

View File

@@ -13,32 +13,36 @@
"test:coverage": "vitest run --coverage",
"lint": "eslint . --ext ts,tsx"
},
"keywords": ["basil", "web"],
"keywords": [
"basil",
"web"
],
"license": "MIT",
"dependencies": {
"@basil/shared": "^1.0.0",
"@hello-pangea/dnd": "^18.0.1",
"axios": "^1.6.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"axios": "^1.6.5"
"react-router-dom": "^6.21.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^1.2.0",
"@vitest/ui": "^1.2.0",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"jsdom": "^23.2.0",
"typescript": "^5.3.3",
"vite": "^5.0.10",
"vitest": "^1.2.0",
"@vitest/ui": "^1.2.0",
"@vitest/coverage-v8": "^1.2.0",
"@testing-library/react": "^14.1.2",
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/user-event": "^14.5.2",
"jsdom": "^23.2.0"
"vitest": "^1.2.0"
}
}

View File

@@ -122,6 +122,13 @@ function CookingMode() {
}
};
const scaleServings = (multiplier: number) => {
if (recipe?.servings) {
const newServings = Math.round(recipe.servings * multiplier);
setCurrentServings(newServings > 0 ? newServings : 1);
}
};
const getScaledIngredientText = (ingredient: Ingredient): string => {
let ingredientStr = '';
if (ingredient.amount && ingredient.unit) {
@@ -226,13 +233,29 @@ function CookingMode() {
<div className="cooking-mode-controls">
{recipe.servings && currentServings !== null && (
<div className="servings-control">
<button onClick={decrementServings} disabled={currentServings <= 1}>
</button>
<span>Servings: {currentServings}</span>
<button onClick={incrementServings}>
+
</button>
<div className="servings-adjuster">
<button onClick={decrementServings} disabled={currentServings <= 1}>
</button>
<span>Servings: {currentServings}</span>
<button onClick={incrementServings}>
+
</button>
</div>
<div className="quick-scale-buttons">
<button onClick={() => scaleServings(0.5)} className="scale-button" title="Half recipe">
½×
</button>
<button onClick={() => scaleServings(1.5)} className="scale-button" title="1.5× recipe">
1.5×
</button>
<button onClick={() => scaleServings(2)} className="scale-button" title="Double recipe">
2×
</button>
<button onClick={() => scaleServings(3)} className="scale-button" title="Triple recipe">
3×
</button>
</div>
</div>
)}

View File

@@ -53,6 +53,13 @@ function RecipeDetail() {
setCurrentServings(recipe?.servings || null);
};
const scaleServings = (multiplier: number) => {
if (recipe?.servings) {
const newServings = Math.round(recipe.servings * multiplier);
setCurrentServings(newServings > 0 ? newServings : 1);
}
};
const handleDelete = async () => {
if (!id || !confirm('Are you sure you want to delete this recipe?')) {
return;
@@ -140,18 +147,34 @@ function RecipeDetail() {
{recipe.totalTime && <span>Total: {recipe.totalTime} min</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
<div className="servings-adjuster">
<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 className="quick-scale-buttons">
<button onClick={() => scaleServings(0.5)} className="scale-button" title="Half recipe">
½×
</button>
<button onClick={() => scaleServings(1.5)} className="scale-button" title="1.5× recipe">
1.5×
</button>
<button onClick={() => scaleServings(2)} className="scale-button" title="Double recipe">
2×
</button>
<button onClick={() => scaleServings(3)} className="scale-button" title="Triple recipe">
3×
</button>
</div>
</div>
)}
</div>

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Recipe, RecipeSection, Ingredient, Instruction } from '@basil/shared';
import { recipesApi } from '../services/api';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
interface RecipeFormProps {
initialRecipe?: Partial<Recipe>;
@@ -147,6 +148,20 @@ function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
setSections(newSections);
};
const reorderSectionInstructions = (sectionIndex: number, result: DropResult) => {
if (!result.destination) return;
const newSections = [...sections];
const items = Array.from(newSections[sectionIndex].instructions);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
// Update step numbers
const updatedItems = items.map((item, index) => ({ ...item, step: index + 1 }));
newSections[sectionIndex].instructions = updatedItems;
setSections(newSections);
};
// Simple mode ingredient management
const addIngredient = () => {
setIngredients([
@@ -185,6 +200,18 @@ function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
setInstructions(newInstructions);
};
const reorderInstructions = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(instructions);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
// Update step numbers
const updatedItems = items.map((item, index) => ({ ...item, step: index + 1 }));
setInstructions(updatedItems);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !initialRecipe?.id) return;
@@ -573,49 +600,77 @@ function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
{/* Section Instructions */}
<div className="subsection">
<h5>Instructions</h5>
{section.instructions.map((instruction, instructionIndex) => (
<div key={instructionIndex} className="instruction-row">
<div className="instruction-number">{instruction.step}</div>
<div className="instruction-content">
<input
type="text"
value={instruction.timing}
onChange={(e) =>
updateSectionInstruction(
sectionIndex,
instructionIndex,
'timing',
e.target.value
)
}
placeholder="Timing (optional, e.g., 8:00am)"
className="instruction-timing-input"
/>
<textarea
value={instruction.text}
onChange={(e) =>
updateSectionInstruction(
sectionIndex,
instructionIndex,
'text',
e.target.value
)
}
placeholder="Instruction text *"
required
/>
</div>
{section.instructions.length > 1 && (
<button
type="button"
onClick={() => removeSectionInstruction(sectionIndex, instructionIndex)}
className="btn-remove"
>
×
</button>
<DragDropContext onDragEnd={(result) => reorderSectionInstructions(sectionIndex, result)}>
<Droppable droppableId={`section-${sectionIndex}-instructions`}>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef}>
{section.instructions.map((instruction, instructionIndex) => (
<Draggable
key={`section-${sectionIndex}-instruction-${instructionIndex}`}
draggableId={`section-${sectionIndex}-instruction-${instructionIndex}`}
index={instructionIndex}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className={`instruction-row ${snapshot.isDragging ? 'dragging' : ''}`}
>
<div
{...provided.dragHandleProps}
className="instruction-drag-handle"
title="Drag to reorder"
>
</div>
<div className="instruction-number">{instruction.step}</div>
<div className="instruction-content">
<input
type="text"
value={instruction.timing}
onChange={(e) =>
updateSectionInstruction(
sectionIndex,
instructionIndex,
'timing',
e.target.value
)
}
placeholder="Timing (optional, e.g., 8:00am)"
className="instruction-timing-input"
/>
<textarea
value={instruction.text}
onChange={(e) =>
updateSectionInstruction(
sectionIndex,
instructionIndex,
'text',
e.target.value
)
}
placeholder="Instruction text *"
required
/>
</div>
{section.instructions.length > 1 && (
<button
type="button"
onClick={() => removeSectionInstruction(sectionIndex, instructionIndex)}
className="btn-remove"
>
×
</button>
)}
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</div>
))}
</Droppable>
</DragDropContext>
<button
type="button"
onClick={() => addSectionInstruction(sectionIndex)}
@@ -676,35 +731,63 @@ function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
{/* Instructions */}
<div className="form-section">
<h3>Instructions</h3>
{instructions.map((instruction, index) => (
<div key={index} className="instruction-row">
<div className="instruction-number">{instruction.step}</div>
<div className="instruction-content">
<input
type="text"
value={instruction.timing}
onChange={(e) => updateInstruction(index, 'timing', e.target.value)}
placeholder="Timing (optional, e.g., 8:00am)"
className="instruction-timing-input"
/>
<textarea
value={instruction.text}
onChange={(e) => updateInstruction(index, 'text', e.target.value)}
placeholder="Instruction text *"
required
/>
</div>
{instructions.length > 1 && (
<button
type="button"
onClick={() => removeInstruction(index)}
className="btn-remove"
>
×
</button>
<DragDropContext onDragEnd={reorderInstructions}>
<Droppable droppableId="instructions">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef}>
{instructions.map((instruction, index) => (
<Draggable
key={`instruction-${index}`}
draggableId={`instruction-${index}`}
index={index}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className={`instruction-row ${snapshot.isDragging ? 'dragging' : ''}`}
>
<div
{...provided.dragHandleProps}
className="instruction-drag-handle"
title="Drag to reorder"
>
</div>
<div className="instruction-number">{instruction.step}</div>
<div className="instruction-content">
<input
type="text"
value={instruction.timing}
onChange={(e) => updateInstruction(index, 'timing', e.target.value)}
placeholder="Timing (optional, e.g., 8:00am)"
className="instruction-timing-input"
/>
<textarea
value={instruction.text}
onChange={(e) => updateInstruction(index, 'text', e.target.value)}
placeholder="Instruction text *"
required
/>
</div>
{instructions.length > 1 && (
<button
type="button"
onClick={() => removeInstruction(index)}
className="btn-remove"
>
×
</button>
)}
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</div>
))}
</Droppable>
</DragDropContext>
<button type="button" onClick={addInstruction} className="btn-secondary">
+ Add Instruction
</button>

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Recipe, Ingredient, Instruction, RecipeSection, Tag } from '@basil/shared';
import { recipesApi, tagsApi } from '../services/api';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import '../styles/UnifiedRecipeEdit.css';
interface MappingChange {
@@ -92,7 +93,10 @@ function UnifiedEditRecipe() {
setServings(loadedRecipe.servings?.toString() || '');
setCuisine(loadedRecipe.cuisine || '');
setRecipeCategories(loadedRecipe.categories || []);
setRecipeTags(loadedRecipe.tags || []);
// Handle tags - API returns array of {tag: {id, name}} objects, we need string[]
const tagNames = (loadedRecipe.tags as any)?.map((t: any) => t.tag?.name || t).filter(Boolean) || [];
setRecipeTags(tagNames);
// Set sections or simple mode
const hasSections = !!(loadedRecipe.sections && loadedRecipe.sections.length > 0);
@@ -301,6 +305,19 @@ function UnifiedEditRecipe() {
setHasChanges(true);
};
const reorderInstructions = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(instructions);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
// Update step numbers
const updatedItems = items.map((item, index) => ({ ...item, step: index + 1 }));
setInstructions(updatedItems);
setHasChanges(true);
};
// Drag and drop
const handleIngredientDragStart = (ingredient: Ingredient) => {
setDraggedIngredient(ingredient);
@@ -1099,132 +1116,161 @@ function UnifiedEditRecipe() {
<div className="instructions-panel">
<h3>Instructions</h3>
<ul className="instructions-list">
{allInstructions.map((instruction, index) => {
const isEditing = editingInstructionId === instruction.id;
const mappedIngredients = getMappedIngredientsForInstruction(
instruction.id || ''
);
const isDragOver = dragOverInstructionId === instruction.id;
return (
<li
key={instruction.id || index}
className={`instruction-item ${isDragOver ? 'drag-over' : ''}`}
onDragOver={(e) => handleInstructionDragOver(e, instruction.id || '')}
onDragLeave={handleInstructionDragLeave}
onDrop={(e) => handleInstructionDrop(e, instruction.id || '')}
<DragDropContext onDragEnd={reorderInstructions}>
<Droppable droppableId="instructions">
{(provided) => (
<ul
className="instructions-list"
{...provided.droppableProps}
ref={provided.innerRef}
>
<div className="instruction-header">
<span className="step-number">Step {instruction.step}</span>
{allInstructions.map((instruction, index) => {
const isEditing = editingInstructionId === instruction.id;
const mappedIngredients = getMappedIngredientsForInstruction(
instruction.id || ''
);
const isDragOver = dragOverInstructionId === instruction.id;
{!isEditing && (
<div className="instruction-controls">
<button
className="btn-edit-instruction"
onClick={() => startEditingInstruction(instruction)}
>
Edit
</button>
<button
className="btn-delete-instruction"
onClick={() => removeInstruction(index)}
>
Delete
</button>
</div>
)}
</div>
{isEditing ? (
<>
<input
type="text"
className="instruction-timing-input"
value={editingInstructionTiming}
onChange={(e) => setEditingInstructionTiming(e.target.value)}
placeholder="Timing (optional, e.g., 8:00am)"
/>
<textarea
className="instruction-text-input"
value={editingInstructionText}
onChange={(e) => setEditingInstructionText(e.target.value)}
placeholder="Instruction text"
autoFocus
/>
<div className="instruction-edit-actions">
<button
className="btn-save-instruction"
onClick={saveEditingInstruction}
>
Save
</button>
<button
className="btn-cancel-instruction"
onClick={cancelEditingInstruction}
>
Cancel
</button>
</div>
</>
) : (
<>
{instruction.timing && (
<div className="instruction-timing-display">
{instruction.timing}
</div>
)}
<div
className="instruction-text-display"
onClick={() => startEditingInstruction(instruction)}
title="Click to edit"
return (
<Draggable
key={instruction.id || `instruction-${index}`}
draggableId={instruction.id || `instruction-${index}`}
index={index}
>
{instruction.text || <em>Click to add instruction text</em>}
</div>
</>
)}
{/* Drop zone for ingredients */}
<div className="drop-zone">
<span className="drop-zone-header">
Ingredients for this step:
</span>
{mappedIngredients.length === 0 ? (
<p className="no-ingredients-mapped">
Drag ingredients here or use bulk actions
</p>
) : (
<ul className="mapped-ingredients-list">
{mappedIngredients.map((ingredient) => (
{(provided, snapshot) => (
<li
key={ingredient.id}
className="mapped-ingredient-item"
ref={provided.innerRef}
{...provided.draggableProps}
className={`instruction-item ${isDragOver ? 'drag-over' : ''} ${snapshot.isDragging ? 'dragging' : ''}`}
onDragOver={(e) => handleInstructionDragOver(e, instruction.id || '')}
onDragLeave={handleInstructionDragLeave}
onDrop={(e) => handleInstructionDrop(e, instruction.id || '')}
>
<span className="mapped-ingredient-text">
{getIngredientText(ingredient)}
</span>
<button
className="btn-remove-ingredient"
onClick={() =>
removeIngredientFromInstruction(
ingredient.id || '',
instruction.id || ''
)
}
title="Remove ingredient from this step"
>
</button>
<div className="instruction-header">
<div className="instruction-header-left">
<div
{...provided.dragHandleProps}
className="instruction-drag-handle"
title="Drag to reorder"
>
</div>
<span className="step-number">Step {instruction.step}</span>
</div>
{!isEditing && (
<div className="instruction-controls">
<button
className="btn-edit-instruction"
onClick={() => startEditingInstruction(instruction)}
>
Edit
</button>
<button
className="btn-delete-instruction"
onClick={() => removeInstruction(index)}
>
Delete
</button>
</div>
)}
</div>
{isEditing ? (
<>
<input
type="text"
className="instruction-timing-input"
value={editingInstructionTiming}
onChange={(e) => setEditingInstructionTiming(e.target.value)}
placeholder="Timing (optional, e.g., 8:00am)"
/>
<textarea
className="instruction-text-input"
value={editingInstructionText}
onChange={(e) => setEditingInstructionText(e.target.value)}
placeholder="Instruction text"
autoFocus
/>
<div className="instruction-edit-actions">
<button
className="btn-save-instruction"
onClick={saveEditingInstruction}
>
Save
</button>
<button
className="btn-cancel-instruction"
onClick={cancelEditingInstruction}
>
Cancel
</button>
</div>
</>
) : (
<>
{instruction.timing && (
<div className="instruction-timing-display">
{instruction.timing}
</div>
)}
<div
className="instruction-text-display"
onClick={() => startEditingInstruction(instruction)}
title="Click to edit"
>
{instruction.text || <em>Click to add instruction text</em>}
</div>
</>
)}
{/* Drop zone for ingredients */}
<div className="drop-zone">
<span className="drop-zone-header">
Ingredients for this step:
</span>
{mappedIngredients.length === 0 ? (
<p className="no-ingredients-mapped">
Drag ingredients here or use bulk actions
</p>
) : (
<ul className="mapped-ingredients-list">
{mappedIngredients.map((ingredient) => (
<li
key={ingredient.id}
className="mapped-ingredient-item"
>
<span className="mapped-ingredient-text">
{getIngredientText(ingredient)}
</span>
<button
className="btn-remove-ingredient"
onClick={() =>
removeIngredientFromInstruction(
ingredient.id || '',
instruction.id || ''
)
}
title="Remove ingredient from this step"
>
</button>
</li>
))}
</ul>
)}
</div>
</li>
))}
</ul>
)}
</div>
</li>
);
})}
</ul>
)}
</Draggable>
);
})}
{provided.placeholder}
</ul>
)}
</Droppable>
</DragDropContext>
<button className="btn-add-instruction" onClick={addInstruction}>
+ Add Instruction

View File

@@ -525,6 +525,14 @@
box-shadow: 0 4px 12px rgba(46, 125, 50, 0.2);
}
.instruction-item.dragging {
opacity: 0.6;
background-color: #f5f5f5;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
border-color: #1976d2;
transform: rotate(2deg);
}
.instruction-header {
display: flex;
justify-content: space-between;
@@ -532,6 +540,31 @@
margin-bottom: 1rem;
}
.instruction-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.instruction-drag-handle {
cursor: grab;
color: #999;
font-size: 1.3rem;
display: flex;
align-items: center;
padding: 0.25rem;
user-select: none;
transition: color 0.2s;
}
.instruction-drag-handle:hover {
color: #1976d2;
}
.instruction-drag-handle:active {
cursor: grabbing;
}
.step-number {
font-size: 1.3rem;
font-weight: 700;

View File

@@ -84,11 +84,14 @@ function fractionToDecimal(fraction: string): number {
function parseAmount(amountStr: string): { value: number | null; range: { min: number; max: number } | null } {
amountStr = amountStr.trim();
// Replace unicode fractions
// Replace unicode fractions with decimal equivalents
for (const [unicode, decimal] of Object.entries(UNICODE_FRACTIONS)) {
amountStr = amountStr.replace(unicode, ` ${decimal}`);
}
// Clean up extra whitespace that might have been introduced
amountStr = amountStr.replace(/\s+/g, ' ').trim();
// Handle ranges: "2-3", "1 to 2", "1-2"
const rangeMatch = amountStr.match(/^(\d+(?:\.\d+)?)\s*(?:-|to)\s*(\d+(?:\.\d+)?)$/i);
if (rangeMatch) {
@@ -97,7 +100,7 @@ function parseAmount(amountStr: string): { value: number | null; range: { min: n
return { value: null, range: { min, max } };
}
// Handle mixed numbers: "1 1/2", "2 3/4"
// Handle mixed numbers: "1 1/2", "2 3/4", "1 1/2" (with any amount of whitespace)
const mixedMatch = amountStr.match(/^(\d+)\s+(\d+)\/(\d+)$/);
if (mixedMatch) {
const whole = parseFloat(mixedMatch[1]);
@@ -105,6 +108,18 @@ function parseAmount(amountStr: string): { value: number | null; range: { min: n
return { value: whole + fraction, range: null };
}
// Also try to handle space-separated numbers that might be part of decimal representation
// e.g., "2 0.25" should be treated as "2.25"
const spaceDecimalMatch = amountStr.match(/^(\d+)\s+(\d+(?:\.\d+)?)$/);
if (spaceDecimalMatch) {
const whole = parseFloat(spaceDecimalMatch[1]);
const decimal = parseFloat(spaceDecimalMatch[2]);
// Only treat as addition if decimal part is < 1 (otherwise it's likely separate numbers)
if (decimal < 1) {
return { value: whole + decimal, range: null };
}
}
// Handle simple fractions: "1/2", "3/4"
if (amountStr.includes('/')) {
return { value: fractionToDecimal(amountStr), range: null };
@@ -125,15 +140,16 @@ function parseAmount(amountStr: string): { value: number | null; range: { min: n
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,
// Check for non-scalable patterns at the START of the ingredient
// These patterns should only make it non-scalable if they appear early in the string
// Not if they're notes at the end like "2 cups flour, plus more as needed"
const startNonScalablePatterns = [
/^to taste/i,
/^optional/i,
/^for (?:serving|garnish|dusting)/i,
];
const isNonScalable = nonScalablePatterns.some(pattern => pattern.test(ingredientStr));
const isNonScalable = startNonScalablePatterns.some(pattern => pattern.test(ingredientStr));
if (isNonScalable) {
return {