feat: implement interactive recipe structure and method editor with ingredient parsing support

This commit is contained in:
2026-08-19 17:48:35 -05:00
parent 011a479839
commit 55e0e5efc9
11 changed files with 888 additions and 566 deletions
@@ -1,5 +1,5 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { parseIngredientsWithOllama } from "../../../../../lib/ingredient-parser"; import { parseIngredientsDeterministic } from "../../../../../lib/ingredient-parser";
import { readOnlyMode } from "../../../../../lib/runtime"; import { readOnlyMode } from "../../../../../lib/runtime";
export const prerender = false; export const prerender = false;
@@ -10,7 +10,7 @@ export const POST: APIRoute = async ({ request }) => {
const body = await request.json() as { text?: unknown }; const body = await request.json() as { text?: unknown };
if (typeof body.text !== "string") return Response.json({ error: "Ingredient text is required." }, { status: 400 }); if (typeof body.text !== "string") return Response.json({ error: "Ingredient text is required." }, { status: 400 });
if (body.text.length > 20_000) return Response.json({ error: "Ingredient text is too long." }, { status: 413 }); if (body.text.length > 20_000) return Response.json({ error: "Ingredient text is too long." }, { status: 413 });
return Response.json(await parseIngredientsWithOllama(body.text)); return Response.json(parseIngredientsDeterministic(body.text));
} catch (error) { } catch (error) {
const message = error instanceof Error && error.name === "TimeoutError" const message = error instanceof Error && error.name === "TimeoutError"
? "Ingredient parser timed out. Try again." ? "Ingredient parser timed out. Try again."
@@ -473,7 +473,7 @@ database.close();
const editor=document.createElement('div'),chips=document.createElement('div'),entry=document.createElement('input'); const editor=document.createElement('div'),chips=document.createElement('div'),entry=document.createElement('input');
editor.className='tag-chip-editor';chips.className='tag-chip-list';entry.className='tag-chip-entry';entry.placeholder='Tag Name'; editor.className='tag-chip-editor';chips.className='tag-chip-list';entry.className='tag-chip-entry';entry.placeholder='Tag Name';
let tags=tagSource.value.split(',').map(value=>value.trim()).filter(Boolean); let tags=tagSource.value.split(',').map(value=>value.trim()).filter(Boolean);
const render=()=>{chips.replaceChildren(...tags.map(tag=>{const chip=document.createElement('span'),label=document.createElement('b'),remove=document.createElement('button');label.textContent=tag;remove.type='button';remove.textContent='×';remove.ariaLabel=`Remove ${tag}`;remove.onclick=()=>{tags=tags.filter(value=>value!==tag);render();setIngredientDirty()};chip.append(label,remove);return chip}));tagSource.value=tags.join(', ')}; const render=()=>{chips.replaceChildren(...tags.map(tag=>{const chip=document.createElement('span'),label=document.createElement('b'),remove=document.createElement('button');label.textContent=tag;remove.type='button';remove.textContent='×';remove.ariaLabel=`Remove ${tag}`;const removeTag=(e)=>{e?.stopPropagation?.();tags=tags.filter(value=>value!==tag);tagSource.value=tags.join(', ');render();setIngredientDirty()};remove.onclick=removeTag;chip.onclick=removeTag;chip.append(label,remove);return chip}));tagSource.value=tags.join(', ')};
const add=()=>{const tag=entry.value.trim().replace(/^#+/,'');if(tag&&!tags.some(value=>value.toLowerCase()===tag.toLowerCase())){tags.push(tag);setIngredientDirty()}entry.value='';render()}; const add=()=>{const tag=entry.value.trim().replace(/^#+/,'');if(tag&&!tags.some(value=>value.toLowerCase()===tag.toLowerCase())){tags.push(tag);setIngredientDirty()}entry.value='';render()};
entry.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===','){event.preventDefault();add()}else if(event.key==='Backspace'&&!entry.value&&tags.length){tags.pop();render();setIngredientDirty()}});entry.addEventListener('blur',add);tagSource.after(editor);editor.append(chips,entry);render(); entry.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===','){event.preventDefault();add()}else if(event.key==='Backspace'&&!entry.value&&tags.length){tags.pop();render();setIngredientDirty()}});entry.addEventListener('blur',add);tagSource.after(editor);editor.append(chips,entry);render();
} }
+3 -5
View File
@@ -202,8 +202,8 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
<section class="recipe-overview-strip"> <section class="recipe-overview-strip">
<form method="post" id="recipe-details-form" class="editor-form recipe-overview-form"> <form method="post" id="recipe-details-form" class="editor-form recipe-overview-form">
<input type="hidden" name="save_version" value={recipe.save_version} /> <input type="hidden" name="save_version" value={recipe.save_version} />
{saved && <div class="success-notice">Changes saved.</div>} {saved && <div class="success-notice"><span>Changes saved.</span><button type="button" class="notice-dismiss-btn" onclick="this.parentElement.remove()" aria-label="Dismiss">×</button></div>}
{error && <div class="notice">{error}</div>} {error && <div class="notice"><span>{error}</span><button type="button" class="notice-dismiss-btn" onclick="this.parentElement.remove()" aria-label="Dismiss">×</button></div>}
<div class:list={["inline-yield-editor",{"auto-calculated":autoYield}]}> <div class:list={["inline-yield-editor",{"auto-calculated":autoYield}]}>
<span class="yield-title-label">Total Yield</span> <span class="yield-title-label">Total Yield</span>
<div class="yield-inputs-row"> <div class="yield-inputs-row">
@@ -264,7 +264,6 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
</select> </select>
<input name="storage_condition" value={shelfLife?.storage_condition??""} placeholder="Storage condition"/> <input name="storage_condition" value={shelfLife?.storage_condition??""} placeholder="Storage condition"/>
</fieldset> </fieldset>
<label><span>Station</span><input name="station" value={additional.station??""} placeholder="Station Name"/></label>
<label><span>Tags</span><input name="tags" value={tags} placeholder="Tag Name"/></label> <label><span>Tags</span><input name="tags" value={tags} placeholder="Tag Name"/></label>
</form> </form>
</section> </section>
@@ -444,7 +443,6 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
<section class="recipe-additional-view"> <section class="recipe-additional-view">
<div> <div>
<h2>Additional details</h2> <h2>Additional details</h2>
{additional.station&&<p><strong>Station</strong><span>{additional.station}</span></p>}
{shelfLife&&<p><strong>Shelf life</strong><span>{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}</span></p>} {shelfLife&&<p><strong>Shelf life</strong><span>{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}</span></p>}
{JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>} {JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>}
</div> </div>
@@ -476,7 +474,7 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
{editing&&<script is:inline> {editing&&<script is:inline>
let recipeDirty=false; let recipeDirty=false;
const setRecipeDirty=(value=true)=>{recipeDirty=value;document.querySelector('#recipe-done')?.classList.toggle('dirty',value)}; const setRecipeDirty=(value=true)=>{recipeDirty=value;document.querySelector('#recipe-done')?.classList.toggle('dirty',value)};
const setupTagEditor=()=>{const source=document.querySelector('#recipe-additional-form input[name="tags"]');if(!source||source.dataset.enhanced)return;source.dataset.enhanced='1';source.type='hidden';const editor=document.createElement('div'),chips=document.createElement('div'),entry=document.createElement('input');editor.className='tag-chip-editor';chips.className='tag-chip-list';entry.className='tag-chip-entry';entry.placeholder='Tag Name';let tags=source.value.split(',').map(value=>value.trim()).filter(Boolean);const render=()=>{chips.replaceChildren(...tags.map(tag=>{const chip=document.createElement('span'),label=document.createElement('b'),remove=document.createElement('button');label.textContent=tag;remove.type='button';remove.textContent='×';remove.ariaLabel=`Remove ${tag}`;remove.onclick=()=>{tags=tags.filter(value=>value!==tag);source.value=tags.join(', ');render();setRecipeDirty()};chip.append(label,remove);return chip}));source.value=tags.join(', ')};const add=()=>{const tag=entry.value.trim().replace(/^#+/,'');if(tag&&!tags.some(value=>value.toLowerCase()===tag.toLowerCase())){tags.push(tag);setRecipeDirty()}entry.value='';render()};entry.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===','){event.preventDefault();add()}else if(event.key==='Backspace'&&!entry.value&&tags.length){tags.pop();render();setRecipeDirty()}});entry.addEventListener('blur',add);source.after(editor);editor.append(chips,entry);render()}; const setupTagEditor=()=>{const source=document.querySelector('#recipe-additional-form input[name="tags"]');if(!source||source.dataset.enhanced)return;source.dataset.enhanced='1';source.type='hidden';const editor=document.createElement('div'),chips=document.createElement('div'),entry=document.createElement('input');editor.className='tag-chip-editor';chips.className='tag-chip-list';entry.className='tag-chip-entry';entry.placeholder='Tag Name';let tags=source.value.split(',').map(value=>value.trim()).filter(Boolean);const render=()=>{chips.replaceChildren(...tags.map(tag=>{const chip=document.createElement('span'),label=document.createElement('b'),remove=document.createElement('button');label.textContent=tag;remove.type='button';remove.textContent='×';remove.ariaLabel=`Remove ${tag}`;const removeTag=(e)=>{e?.stopPropagation?.();tags=tags.filter(value=>value!==tag);source.value=tags.join(', ');render();setRecipeDirty()};remove.onclick=removeTag;chip.onclick=removeTag;chip.append(label,remove);return chip}));source.value=tags.join(', ')};const add=()=>{const tag=entry.value.trim().replace(/^#+/,'');if(tag&&!tags.some(value=>value.toLowerCase()===tag.toLowerCase())){tags.push(tag);setRecipeDirty()}entry.value='';render()};entry.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===','){event.preventDefault();add()}else if(event.key==='Backspace'&&!entry.value&&tags.length){tags.pop();render();setRecipeDirty()}});entry.addEventListener('blur',add);source.after(editor);editor.append(chips,entry);render()};
document.addEventListener('recipe:dirty',event=>setRecipeDirty(Boolean(event.detail))); document.addEventListener('recipe:dirty',event=>setRecipeDirty(Boolean(event.detail)));
document.querySelector('#recipe-details-form')?.addEventListener('input',()=>setRecipeDirty()); document.querySelector('#recipe-details-form')?.addEventListener('input',()=>setRecipeDirty());
document.querySelector('#recipe-additional-form')?.addEventListener('input',()=>setRecipeDirty()); document.querySelector('#recipe-additional-form')?.addEventListener('input',()=>setRecipeDirty());
+422 -420
View File
@@ -29,6 +29,8 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
setRowQuery, setRowQuery,
focusedItem, focusedItem,
setFocusedItem, setFocusedItem,
draftNotes,
setDraftNotes,
bulkOpen, bulkOpen,
setBulkOpen, setBulkOpen,
bulkText, bulkText,
@@ -46,6 +48,7 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
methodTarget, methodTarget,
state, state,
message, message,
setMessage,
dragging, dragging,
setDragging, setDragging,
calculatePercent, calculatePercent,
@@ -105,7 +108,17 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
</div> </div>
{message && ( {message && (
<p class={state === "error" ? "notice" : "success-notice"}>{message}</p> <div class={`editor-notice-banner ${state === "error" ? "notice" : "success-notice"}`}>
<span>{message}</span>
<button
type="button"
class="notice-dismiss-btn"
aria-label="Dismiss notice"
onClick={() => setMessage("")}
>
×
</button>
</div>
)} )}
{autoYield && unconvertedYieldItems() > 0 && ( {autoYield && unconvertedYieldItems() > 0 && (
@@ -116,436 +129,425 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
</p> </p>
)} )}
<div class="table-wrap"> <div class="components-container">
<table {data.components.map((component, componentIndex) => {
class={`editor-items${ const componentId = component.id;
calculatePercent && percentMode === "bakers" ? " bakers-mode" : "" const currentQuery = query[componentId] ?? "";
}`} const currentDraftNote = draftNotes[componentId] ?? "";
>
<thead>
<tr>
<th class="quantity-column">Qty</th>
<th class="unit-column">Unit</th>
<th class="entity-column">Ingredient / Recipe</th>
<th class="notes-column">Notes</th>
{calculatePercent && percentMode === "bakers" && (
<th class="base-column">Base</th>
)}
{calculatePercent && <th class="percent-column">%</th>}
<th class="actions-column"></th>
</tr>
</thead>
<tbody>
{data.components.map((component, componentIndex) => (
<Fragment key={component.id}>
{(data.components.length > 1 ||
Boolean(component.name?.trim())) && (
<tr
class={`component-header-row${
dragging?.kind === "component" &&
dragging.index === componentIndex
? " dragging"
: ""
}`}
key={`header-${component.id}`}
onDragOver={(event) => event.preventDefault()}
onDrop={() => {
if (dragging?.kind === "component")
setData((current) => ({
...current,
components: move(
current.components,
dragging.index,
componentIndex
),
}));
setDragging(undefined);
}}
>
<td
colSpan={
calculatePercent
? percentMode === "bakers"
? 7
: 6
: 5
}
>
<div class="component-header-content">
<input
class="component-name-input"
aria-label="Component name"
placeholder="Edit Header"
value={component.name}
onInput={(event) =>
updateComponent(componentIndex, (value) => ({
...value,
name: event.currentTarget.value,
}))
}
/>
<div class="component-header-actions">
<button
type="button"
class="remove-component-btn"
title="Delete section"
aria-label="Delete section"
onClick={() =>
setData((current) => ({
...current,
components: current.components.filter(
(_, index) => index !== componentIndex
),
}))
}
>
<svg
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
>
<path
fill="currentColor"
d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"
/>
</svg>
</button>
<span
class="drag-handle"
title="Drag to reorder section"
aria-label="Drag to reorder section"
draggable
onDragStart={() =>
setDragging({
kind: "component",
index: componentIndex,
})
}
>
<svg
viewBox="0 0 24 24"
width="22"
height="22"
aria-hidden="true"
>
<path
fill="currentColor"
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"
/>
</svg>
</span>
</div>
</div>
</td>
</tr>
)}
{(component.notes ?? []).map((note, noteIndex) => ( return (
<tr <div class="component-section-block" key={component.id}>
class="component-note-row" <div class="table-wrap">
key={`note-${component.id}-${noteIndex}`} <table
> class={`editor-items${
<td calculatePercent && percentMode === "bakers"
colSpan={ ? " bakers-mode"
calculatePercent : ""
? percentMode === "bakers" }`}
? 7 >
: 6 {componentIndex === 0 && (
: 5 <thead>
} <tr>
> <th class="quantity-column">Qty</th>
<div class="component-note-content"> <th class="unit-column">Unit</th>
<input <th class="entity-column">Ingredient / Recipe</th>
class="component-notes" <th class="notes-column">Notes</th>
aria-label={`${component.name} note ${ {calculatePercent && percentMode === "bakers" && (
noteIndex + 1 <th class="base-column">Base</th>
}`} )}
value={note} {calculatePercent && <th class="percent-column">%</th>}
placeholder="Add section note" <th class="actions-column"></th>
onInput={(event) => </tr>
updateComponent(componentIndex, (value) => ({ </thead>
...value, )}
notes: (value.notes ?? []).map((entry, index) => <tbody>
index === noteIndex {(data.components.length > 1 ||
? event.currentTarget.value Boolean(component.name?.trim())) && (
: entry <tr
class={`component-header-row${
dragging?.kind === "component" &&
dragging.index === componentIndex
? " dragging"
: ""
}`}
key={`header-${component.id}`}
onDragOver={(event) => event.preventDefault()}
onDrop={() => {
if (dragging?.kind === "component")
setData((current) => ({
...current,
components: move(
current.components,
dragging.index,
componentIndex
), ),
})) }));
} setDragging(undefined);
/> }}
<button >
type="button" <td
class="remove-row" colSpan={
aria-label="Remove note" calculatePercent
onClick={() => ? percentMode === "bakers"
updateComponent(componentIndex, (value) => ({ ? 7
...value, : 6
notes: (value.notes ?? []).filter( : 5
(_, index) => index !== noteIndex
),
}))
} }
> >
<div class="component-header-content">
</button> <input
</div> class="component-name-input"
</td> aria-label="Component name"
</tr> placeholder="Header Name"
))} value={component.name}
onInput={(event) =>
updateComponent(componentIndex, (value) => ({
...value,
name: event.currentTarget.value,
}))
}
/>
<div class="component-header-actions">
<button
type="button"
class="remove-component-btn"
title="Delete section"
aria-label="Delete section"
onClick={() =>
setData((current) => ({
...current,
components: current.components.filter(
(_, index) => index !== componentIndex
),
}))
}
disabled={data.components.length <= 1}
>
<svg
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
>
<path
fill="currentColor"
d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"
/>
</svg>
</button>
<span
class="drag-handle"
title="Drag to reorder section"
aria-label="Drag to reorder section"
draggable
onDragStart={() =>
setDragging({
kind: "component",
index: componentIndex,
})
}
>
<svg
viewBox="0 0 24 24"
width="22"
height="22"
aria-hidden="true"
>
<path
fill="currentColor"
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"
/>
</svg>
</span>
</div>
</div>
</td>
</tr>
)}
{component.items.map((item, itemIndex) => ( {(component.notes ?? []).map((note, noteIndex) => (
<RecipeItemRow <tr
key={item.id} class="component-note-row"
item={item} key={`note-${component.id}-${noteIndex}`}
componentIndex={componentIndex} >
itemIndex={itemIndex} <td
units={units} colSpan={
ingredients={ingredients} calculatePercent
pendingIngredients={pendingIngredients} ? percentMode === "bakers"
recipes={recipes} ? 7
recipeId={recipeId} : 6
dragging={dragging} : 5
calculatePercent={calculatePercent} }
percentMode={percentMode} >
percentValue={percent(item)} <div class="component-note-content">
focusedItem={focusedItem} <input
rowQueryValue={rowQuery[item.id]} class="component-notes"
onUpdateQuantity={(quantity) => aria-label={`${component.name} note ${
updateComponent(componentIndex, (value) => ({ noteIndex + 1
...value, }`}
items: value.items.map((line, index) => value={note}
index === itemIndex ? { ...line, quantity } : line placeholder="Add section note"
), onInput={(event) =>
})) updateComponent(componentIndex, (value) => ({
} ...value,
onUpdateUnit={(unitId) => notes: (value.notes ?? []).map((entry, index) =>
updateComponent(componentIndex, (value) => ({ index === noteIndex
...value, ? event.currentTarget.value
items: value.items.map((line, index) => : entry
index === itemIndex ? { ...line, unit_id: unitId } : line ),
), }))
})) }
} />
onUpdateNotes={(notes) => <button
updateComponent(componentIndex, (value) => ({ type="button"
...value, class="remove-row"
items: value.items.map((line, index) => aria-label="Remove note"
index === itemIndex ? { ...line, notes } : line onClick={() =>
), updateComponent(componentIndex, (value) => ({
})) ...value,
} notes: (value.notes ?? []).filter(
onUpdateBasis={(basis) => (_, index) => index !== noteIndex
updateComponent(componentIndex, (value) => ({ ),
...value, }))
items: value.items.map((line, index) => }
index === itemIndex >
? { ...line, basis_member: basis }
: line </button>
), </div>
})) </td>
} </tr>
onRemove={() => ))}
updateComponent(componentIndex, (value) => ({
...value, {component.items.map((item, itemIndex) => (
items: value.items.filter( <RecipeItemRow
(_, index) => index !== itemIndex key={item.id}
), item={item}
})) componentIndex={componentIndex}
} itemIndex={itemIndex}
onDragStart={(event) => { units={units}
event.stopPropagation(); ingredients={ingredients}
setDragging({ pendingIngredients={pendingIngredients}
kind: "item", recipes={recipes}
component: componentIndex, recipeId={recipeId}
index: itemIndex, dragging={dragging}
}); calculatePercent={calculatePercent}
percentMode={percentMode}
percentValue={percent(item)}
focusedItem={focusedItem}
rowQueryValue={rowQuery[item.id]}
onUpdateQuantity={(quantity) =>
updateComponent(componentIndex, (value) => ({
...value,
items: value.items.map((line, index) =>
index === itemIndex ? { ...line, quantity } : line
),
}))
}
onUpdateUnit={(unitId) =>
updateComponent(componentIndex, (value) => ({
...value,
items: value.items.map((line, index) =>
index === itemIndex ? { ...line, unit_id: unitId } : line
),
}))
}
onUpdateNotes={(notes) =>
updateComponent(componentIndex, (value) => ({
...value,
items: value.items.map((line, index) =>
index === itemIndex ? { ...line, notes } : line
),
}))
}
onUpdateBasis={(basis) =>
updateComponent(componentIndex, (value) => ({
...value,
items: value.items.map((line, index) =>
index === itemIndex
? { ...line, basis_member: basis }
: line
),
}))
}
onRemove={() =>
updateComponent(componentIndex, (value) => ({
...value,
items: value.items.filter(
(_, index) => index !== itemIndex
),
}))
}
onDragStart={(event) => {
event.stopPropagation();
setDragging({
kind: "item",
component: componentIndex,
index: itemIndex,
});
}}
onDrop={(event) => {
event.stopPropagation();
if (
dragging?.kind === "item" &&
dragging.component === componentIndex
) {
updateComponent(componentIndex, (value) => ({
...value,
items: move(
value.items,
dragging.index,
itemIndex
),
}));
}
setDragging(undefined);
}}
onFocus={() => setFocusedItem(item.id)}
onBlur={() =>
window.setTimeout(
() =>
setFocusedItem((current) =>
current === item.id ? undefined : current
),
120
)
}
onQueryChange={(val) =>
setRowQuery((current) => ({
...current,
[item.id]: val,
}))
}
onSelectChoice={(kind, id, name) =>
replaceLineReference(
componentIndex,
itemIndex,
kind,
id,
name
)
}
onCreatePendingIngredient={(name) =>
createPendingRowIngredient(
componentIndex,
itemIndex,
name
)
}
onSetPercent={(target) =>
setPercent(componentIndex, itemIndex, target)
}
/>
))}
</tbody>
</table>
</div>
<div class="quick-add-bar">
<div class="quick-add-search">
<input
value={currentQuery}
placeholder="1cup onion sliced"
aria-label={`Add ingredient or recipe to ${component.name || "section"}`}
onInput={(event) => {
const value = event.currentTarget.value;
setQuery((current) => ({ ...current, [componentId]: value }));
}} }}
onDrop={(event) => { onKeyDown={(event) => {
event.stopPropagation(); if (event.key === "Enter") {
if ( event.preventDefault();
dragging?.kind === "item" && const val = (query[componentId] ?? "").trim();
dragging.component === componentIndex if (val) {
) { createPendingIngredient(componentIndex, val);
updateComponent(componentIndex, (value) => ({ setQuery((current) => ({ ...current, [componentId]: "" }));
...value, }
items: move(
value.items,
dragging.index,
itemIndex
),
}));
} }
setDragging(undefined);
}} }}
onFocus={() => setFocusedItem(item.id)}
onBlur={() =>
window.setTimeout(
() =>
setFocusedItem((current) =>
current === item.id ? undefined : current
),
120
)
}
onQueryChange={(val) =>
setRowQuery((current) => ({
...current,
[item.id]: val,
}))
}
onSelectChoice={(kind, id, name) =>
replaceLineReference(
componentIndex,
itemIndex,
kind,
id,
name
)
}
onCreatePendingIngredient={(name) =>
createPendingRowIngredient(
componentIndex,
itemIndex,
name
)
}
onSetPercent={(target) =>
setPercent(componentIndex, itemIndex, target)
}
/> />
))} {Boolean(currentQuery.trim()) && (
</Fragment> <div class="recipe-item-results">
))} {[
</tbody> ...[...ingredients, ...pendingIngredients].map((item) => ({
</table> ...item,
</div> kind: "ingredient" as const,
})),
<div class="quick-add-bar"> ...recipes
<div class="quick-add-search"> .filter((item) => item.id !== recipeId)
<input .map((item) => ({ ...item, kind: "recipe" as const })),
value={ ]
query[ .filter((item) => {
data.components[data.components.length - 1]?.id ?? const q = currentQuery.trim().toLowerCase();
data.components[0]?.id ?? return [item.name, ...(item.aliases ?? [])].some((name) =>
"" name.toLowerCase().includes(q)
] ?? "" );
} })
placeholder="1cup onion sliced" .slice(0, 8)
aria-label="Add ingredient or recipe" .map((item) => (
onInput={(event) => { <button
const value = event.currentTarget.value; type="button"
const activeId = key={`${item.kind}-${item.id}`}
data.components[data.components.length - 1]?.id ?? onClick={() => {
data.components[0]?.id; addLine(componentIndex, `${item.kind}:${item.id}`);
if (activeId) setQuery((current) => ({ ...current, [componentId]: "" }));
setQuery((current) => ({ ...current, [activeId]: value })); }}
}} >
onKeyDown={(event) => { <strong>{item.name}</strong>
if (event.key === "Enter") { <small>
event.preventDefault(); {item.kind === "recipe" ? "Recipe" : "Ingredient"}
const activeIndex = data.components.length - 1; </small>
const activeId = </button>
data.components[activeIndex]?.id ?? ))}
data.components[0]?.id; {(() => {
const val = (query[activeId ?? ""] ?? "").trim(); const q = currentQuery.trim();
if (val) { if (
createPendingIngredient(activeIndex, val); q &&
setQuery((current) => ({ ...current, [activeId]: "" })); !ingredients.some(
} (item) => normal(item.name) === normal(q)
} ) &&
}} !pendingIngredients.some(
/> (item) => normal(item.name) === normal(q)
{Boolean( )
( ) {
query[ return (
data.components[data.components.length - 1]?.id ?? <button
data.components[0]?.id ?? type="button"
"" class="create-ingredient-result"
] ?? "" onClick={() => {
).trim() createPendingIngredient(componentIndex, q);
) && ( setQuery((current) => ({
<div class="recipe-item-results"> ...current,
{[ [componentId]: "",
...[...ingredients, ...pendingIngredients].map((item) => ({ }));
...item, }}
kind: "ingredient" as const, >
})), <strong>+ Create {q}</strong>
...recipes <small>New ingredient</small>
.filter((item) => item.id !== recipeId) </button>
.map((item) => ({ ...item, kind: "recipe" as const })), );
] }
.filter((item) => { return null;
const activeId = })()}
data.components[data.components.length - 1]?.id ?? </div>
data.components[0]?.id ?? )}
""; </div>
const q = (query[activeId] ?? "").trim().toLowerCase(); <div class="quick-add-notes">
return [item.name, ...(item.aliases ?? [])].some((name) => <input
name.toLowerCase().includes(q) value={currentDraftNote}
); placeholder="Add notes"
}) aria-label="Add notes"
.slice(0, 8) onInput={(event) => {
.map((item) => ( const val = event.currentTarget.value;
<button setDraftNotes((current) => ({
type="button" ...current,
key={`${item.kind}-${item.id}`} [componentId]: val,
onClick={() => { }));
const activeIndex = data.components.length - 1;
const activeId =
data.components[activeIndex]?.id ??
data.components[0]?.id ??
"";
addLine(activeIndex, `${item.kind}:${item.id}`);
setQuery((current) => ({ ...current, [activeId]: "" }));
}} }}
> />
<strong>{item.name}</strong> </div>
<small> </div>
{item.kind === "recipe" ? "Recipe" : "Ingredient"}
</small>
</button>
))}
{(() => {
const activeId =
data.components[data.components.length - 1]?.id ??
data.components[0]?.id ??
"";
const q = query[activeId] ?? "";
if (
!ingredients.some(
(item) => normal(item.name) === normal(q)
) &&
!pendingIngredients.some(
(item) => normal(item.name) === normal(q)
)
) {
return (
<button
type="button"
class="create-ingredient-result"
onClick={() => {
const activeIndex = data.components.length - 1;
createPendingIngredient(activeIndex, q);
setQuery((current) => ({
...current,
[activeId]: "",
}));
}}
>
<strong>+ Create {q.trim()}</strong>
<small>New ingredient</small>
</button>
);
}
return null;
})()}
</div> </div>
)} );
</div> })}
<div class="quick-add-notes">
<input placeholder="Add notes" aria-label="Add notes" />
</div>
</div> </div>
<div class="structure-bottom-bar"> <div class="structure-bottom-bar">
@@ -23,18 +23,37 @@ export default function RecipeMethodEditor({
onDrop, onDrop,
onOpenBulkPrep, onOpenBulkPrep,
}: Props) { }: Props) {
let stepCount = 0;
const content = ( const content = (
<section class="method-editor"> <section class="method-editor">
<div class="method-header-row"> <div class="method-header-row">
<h2> <h2>
Prep Method <small>{steps.length}</small> Prep Method{" "}
<small>
{steps.filter(
(s) =>
!s.instruction.trim().endsWith(":") &&
!/^\(.+\)$/.test(s.instruction.trim())
).length || steps.length}
</small>
</h2> </h2>
</div> </div>
<ol class="method-steps-list"> <ol class="method-steps-list">
{steps.map((step, index) => { {steps.map((step, index) => {
const stepKind = step.instruction.trim().endsWith(":") const text = step.instruction.trim();
const isHeading = text.endsWith(":") || /^#+\s+/.test(text);
const isNote = /^\(.+\)$/.test(text) || /^note:\s*/i.test(text);
let currentStepNum: number | null = null;
if (!isHeading && !isNote) {
stepCount += 1;
currentStepNum = stepCount;
}
const stepKindClass = isHeading
? " prep-heading" ? " prep-heading"
: /^\(.+\)$/.test(step.instruction.trim()) : isNote
? " prep-note" ? " prep-note"
: ""; : "";
@@ -44,19 +63,37 @@ export default function RecipeMethodEditor({
dragging?.kind === "step" && dragging.index === index dragging?.kind === "step" && dragging.index === index
? " dragging" ? " dragging"
: "" : ""
}${stepKind}`} }${stepKindClass}`}
key={step.id} key={step.id}
onDragOver={(event) => event.preventDefault()} onDragOver={(event) => event.preventDefault()}
onDrop={() => { onDrop={() => {
if (dragging?.kind === "step") onDrop(dragging.index, index); if (dragging?.kind === "step") onDrop(dragging.index, index);
}} }}
> >
<span class="step-num">{index + 1}.</span> <div class="step-indicator">
{isHeading ? (
<span class="prep-heading-tag" title="Section Header">
H
</span>
) : isNote ? (
<span class="prep-note-tag" title="Prep Note">
</span>
) : (
<span class="step-num">{currentStepNum}.</span>
)}
</div>
<div class="step-body"> <div class="step-body">
<textarea <textarea
rows={2} rows={isHeading ? 1 : 2}
class="step-textarea" class="step-textarea"
placeholder="Add Prep Step" placeholder={
isHeading
? "Section Header (e.g. To Sear:)"
: isNote
? "Prep Note (e.g. Can be prepared in advance)"
: "Add Prep Step"
}
value={step.instruction} value={step.instruction}
onInput={(event) => onInput={(event) =>
onStepsChange( onStepsChange(
@@ -117,6 +154,21 @@ export default function RecipeMethodEditor({
})} })}
</ol> </ol>
<div class="method-footer-actions"> <div class="method-footer-actions">
<button
type="button"
class="action-link-btn"
onClick={() =>
onStepsChange([
...steps,
{ id: uid("step"), instruction: "", equipment_ids: [] },
])
}
>
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
</svg>
Add Step
</button>
<button <button
type="button" type="button"
class="action-link-btn" class="action-link-btn"
@@ -453,11 +453,24 @@ export function useRecipeStructure({
setPrepError("Field is required"); setPrepError("Field is required");
return; return;
} }
const additions = lines.map((instruction) => ({ const additions = lines.map((rawLine) => {
id: uid("step"), let instruction = rawLine.trim();
instruction, const isHeader = instruction.endsWith(":") || /^#+\s+/.test(instruction);
equipment_ids: [], if (isHeader) {
})); instruction = `${instruction.replace(/^#+\s*/, "").replace(/:$/, "").trim()}:`;
} else {
instruction = instruction
.replace(/^\s*(?:step\s+)?\d+[\.\)\:\-]\s*/i, "")
.replace(/^\s*\[\d+\]\s*/, "")
.replace(/^\s*[•\-\*]\s*/, "")
.trim();
}
return {
id: uid("step"),
instruction,
equipment_ids: [],
};
});
setData((current) => ({ setData((current) => ({
...current, ...current,
steps: steps:
@@ -654,6 +667,7 @@ export function useRecipeStructure({
baseline, baseline,
state, state,
message, message,
setMessage,
dragging, dragging,
setDragging, setDragging,
calculatePercent, calculatePercent,
+11
View File
@@ -59,6 +59,17 @@ const isApplication = Astro.url.pathname.startsWith("/app") || Astro.url.pathnam
menus().forEach((menu) => menu.addEventListener("toggle", () => { menus().forEach((menu) => menu.addEventListener("toggle", () => {
if (menu instanceof HTMLDetailsElement && menu.open) close(menu); if (menu instanceof HTMLDetailsElement && menu.open) close(menu);
})); }));
document.querySelectorAll(".notice, .success-notice, .entity-notice, .book-notice, .count-notice").forEach((el) => {
if (el.querySelector(".notice-dismiss-btn")) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "notice-dismiss-btn";
btn.textContent = "×";
btn.ariaLabel = "Dismiss notice";
btn.onclick = () => el.remove();
el.appendChild(btn);
});
})(); })();
</script>} </script>}
</body> </body>
+54 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { normalizeIngredientText, validateIngredientParse } from "./ingredient-parser"; import {
normalizeIngredientText,
parseIngredientsDeterministic,
validateIngredientParse,
} from "./ingredient-parser";
describe("ingredient parser", () => { describe("ingredient parser", () => {
it("normalizes copied checklist text and Unicode fractions", () => { it("normalizes copied checklist text and Unicode fractions", () => {
@@ -24,4 +28,53 @@ describe("ingredient parser", () => {
}] }], warnings:[], }] }], warnings:[],
}, "salt")).toThrow("invalid quantity"); }, "salt")).toThrow("invalid quantity");
}); });
it("deterministically parses ingredient lines with headers, quantities, units, and notes", () => {
const raw = `
Dry Mix:
500g flour
1/2 cup semolina
salt to taste
Wet:
5 cloves garlic
3 egg yolks
olive oil (room temp)
`;
const result = parseIngredientsDeterministic(raw);
expect(result.components).toHaveLength(2);
expect(result.components[0].name).toBe("Dry Mix");
expect(result.components[0].items).toHaveLength(3);
expect(result.components[0].items[0]).toMatchObject({
quantity: 500,
unit: "gram",
ingredient: "flour",
});
expect(result.components[0].items[1]).toMatchObject({
quantity: 0.5,
unit: "cup",
ingredient: "semolina",
});
expect(result.components[0].items[2]).toMatchObject({
ingredient: "salt",
note: "to taste",
});
expect(result.components[1].name).toBe("Wet");
expect(result.components[1].items).toHaveLength(3);
expect(result.components[1].items[0]).toMatchObject({
quantity: 5,
unit: "clove",
ingredient: "garlic",
});
expect(result.components[1].items[1]).toMatchObject({
quantity: 3,
ingredient: "egg yolks",
});
expect(result.components[1].items[2]).toMatchObject({
ingredient: "olive oil",
note: "room temp",
});
});
}); });
+220 -121
View File
@@ -29,7 +29,7 @@ const FRACTIONS: Record<string, string> = {
export function normalizeIngredientText(value: string): string { export function normalizeIngredientText(value: string): string {
return value return value
.replace(/[¼½¾⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞]/g, (value) => FRACTIONS[value] ?? value) .replace(/[¼½¾⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞]/g, (match) => FRACTIONS[match] ?? match)
.normalize("NFKC") .normalize("NFKC")
.replace(//g, "/") .replace(//g, "/")
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "") .replace(/[\u200B-\u200D\u2060\uFEFF]/g, "")
@@ -44,143 +44,242 @@ export function normalizeIngredientText(value: string): string {
.join("\n"); .join("\n");
} }
const responseSchema = { function parseFractionValue(raw: string): number | null {
type: "object", const trimmed = raw.trim();
additionalProperties: false, if (!trimmed) return null;
required: ["components", "warnings"], // Handle range like "2-3" or "2 - 3"
properties: { if (/^\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?$/.test(trimmed)) {
components: { const [low, high] = trimmed.split("-").map((v) => Number(v.trim()));
type: "array", return (low + high) / 2;
items: { }
type: "object", // Handle mixed fraction like "1 1/2" or "1-1/2"
additionalProperties: false, const mixed = trimmed.match(/^(\d+)\s+([0-9]+)\/([0-9]+)$/);
required: ["name", "items"], if (mixed) {
properties: { const whole = Number(mixed[1]);
name: { type: "string" }, const num = Number(mixed[2]);
items: { const den = Number(mixed[3]);
type: "array", if (den === 0) return null;
items: { return whole + num / den;
type: "object", }
additionalProperties: false, // Handle simple fraction like "1/2"
required: ["source_line", "quantity", "unit", "ingredient", "preparation", "note", "optional", "alternatives"], const frac = trimmed.match(/^([0-9]+)\/([0-9]+)$/);
properties: { if (frac) {
source_line: { type: "string" }, const num = Number(frac[1]);
quantity: { type: ["number", "null"] }, const den = Number(frac[2]);
unit: { type: ["string", "null"] }, if (den === 0) return null;
ingredient: { type: "string" }, return num / den;
preparation: { type: ["string", "null"] }, }
note: { type: ["string", "null"] }, // Handle decimal or integer
optional: { type: "boolean" }, const num = Number(trimmed);
alternatives: { type: "array", items: { type: "string" } }, return Number.isFinite(num) && num > 0 ? num : null;
}, }
},
},
},
},
},
warnings: { type: "array", items: { type: "string" } },
},
} as const;
const systemPrompt = `You are a purpose-built culinary ingredient parser. Convert normalized recipe ingredient text into JSON only. const UNIT_MAP: Record<string, string> = {
g: "gram", gram: "gram", grams: "gram",
kg: "kilogram", kilogram: "kilogram", kilograms: "kilogram",
oz: "ounce", ounce: "ounce", ounces: "ounce",
lb: "pound", lbs: "pound", pound: "pound", pounds: "pound",
tsp: "teaspoon", tsps: "teaspoon", teaspoon: "teaspoon", teaspoons: "teaspoon",
tbsp: "tablespoon", tbsps: "tablespoon", tbs: "tablespoon", tablespoon: "tablespoon", tablespoons: "tablespoon",
c: "cup", cup: "cup", cups: "cup",
ml: "milliliter", milliliter: "milliliter", milliliters: "milliliter",
l: "liter", liter: "liter", liters: "liter",
clove: "clove", cloves: "clove",
ea: "each", each: "each",
pinch: "pinch", pinches: "pinch",
dash: "dash", dashes: "dash",
slice: "slice", slices: "slice",
sprig: "sprig", sprigs: "sprig",
stalk: "stalk", stalks: "stalk",
head: "head", heads: "head",
bunch: "bunch", bunches: "bunch",
can: "can", cans: "can",
pkg: "package", package: "package", packages: "package",
bottle: "bottle", bottles: "bottle",
};
Rules: export function parseSingleIngredientLine(sourceLine: string): ParsedIngredient {
- Preserve the meaning and never invent an ingredient, amount, unit, or preparation. const line = sourceLine.trim();
- Convert fractions and mixed numbers to decimal quantities. const optional = /\boptional\b/i.test(line);
- Use singular conventional unit names such as gram, ounce, pound, teaspoon, tablespoon, cup, milliliter, liter, or each.
- A line ending in a colon is a component header. Use "Main" when there is no header.
- Group consecutive lines under one component. Do not create a new Main component for each line.
- ingredient contains only the ingredient identity, never its quantity, unit, size, preparation, or note. For example, "15 ounces tomato sauce" becomes quantity 15, unit "ounce", ingredient "tomato sauce".
- Treat sizes such as small, medium, and large as preparation or notes and use unit "each". For example, "1 medium onion, chopped" becomes quantity 1, unit "each", ingredient "onion", preparation "medium; chopped".
- Split a source line containing two independently required ingredients into two items, retaining the same source_line.
- Specifically, "salt and freshly ground black pepper, to taste" becomes separate salt and black pepper items.
- Keep alternatives in alternatives instead of adding them as required items.
- Put physical treatment such as chopped, minced, sliced, freshly ground, dried, or drained in preparation.
- Put serving instructions, "to taste", temperatures, and other qualifications in note.
- Set optional true when the source explicitly says optional.
- Use null quantity and unit when the source supplies none. Do not guess.
- Use null rather than an empty string. Do not repeat alternatives in note.
- Retain every source line. Add a warning for ambiguity.`;
function isNullableString(value: unknown): value is string | null { // Check notes in parentheses
return value === null || typeof value === "string"; let workingLine = line;
let note: string | null = null;
const parenMatch = workingLine.match(/\(([^)]+)\)/);
if (parenMatch) {
const inside = parenMatch[1].trim();
if (!/^\s*optional\s*$/i.test(inside)) {
note = inside;
}
workingLine = workingLine.replace(parenMatch[0], " ").replace(/\s+/g, " ").trim();
}
// Check preparation or notes after comma
let preparation: string | null = null;
const commaIdx = workingLine.indexOf(",");
if (commaIdx > 0) {
const afterComma = workingLine.slice(commaIdx + 1).trim();
workingLine = workingLine.slice(0, commaIdx).trim();
if (afterComma) {
if (/to taste/i.test(afterComma)) {
note = note ? `${note}; ${afterComma}` : afterComma;
} else {
preparation = afterComma;
}
}
}
// Match leading quantity and unit
let quantity: number | null = null;
let unit: string | null = null;
let ingredientName = workingLine;
// Regex pattern 1: Number (fraction/decimal/range) followed by optional unit and rest
const qtyUnitPattern = /^(\d+(?:\.\d+)?(?:\s+\d+\/\d+|\/\d+)?|\d+\/\d+|\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?)\s*([a-zA-Z]+)?\s+(.*)$/;
const match1 = workingLine.match(qtyUnitPattern);
if (match1) {
const qtyVal = parseFractionValue(match1[1]);
const possibleUnit = (match1[2] || "").toLowerCase();
if (qtyVal != null) {
quantity = qtyVal;
if (possibleUnit && UNIT_MAP[possibleUnit]) {
unit = UNIT_MAP[possibleUnit];
ingredientName = match1[3].trim();
} else if (possibleUnit) {
// Not a recognized unit word, it might be the start of the ingredient name
ingredientName = `${possibleUnit} ${match1[3]}`.trim();
} else {
ingredientName = match1[3].trim();
}
}
} else {
// Regex pattern 2: Number directly attached to unit e.g. "500g flour", "250ml water"
const attachedMatch = workingLine.match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*(.*)$/);
if (attachedMatch) {
const qtyVal = parseFractionValue(attachedMatch[1]);
const possibleUnit = attachedMatch[2].toLowerCase();
if (qtyVal != null && UNIT_MAP[possibleUnit]) {
quantity = qtyVal;
unit = UNIT_MAP[possibleUnit];
ingredientName = attachedMatch[3].trim();
}
}
}
// If ingredientName has trailing "to taste"
if (/to taste/i.test(ingredientName)) {
ingredientName = ingredientName.replace(/\bto taste\b/i, "").trim();
if (!note) note = "to taste";
}
// Clean up any remaining artifacts in ingredient name
ingredientName = ingredientName.replace(/^of\s+/i, "").trim();
if (!ingredientName) ingredientName = line;
return {
source_line: line,
quantity,
unit,
ingredient: ingredientName,
preparation,
note,
optional,
alternatives: [],
};
}
export function parseIngredientsDeterministic(text: string): IngredientParseResult {
const normalizedText = normalizeIngredientText(text);
if (!normalizedText) {
throw new Error("Enter at least one ingredient.");
}
const lines = normalizedText.split("\n").map((l) => l.trim()).filter(Boolean);
const components: ParsedIngredientComponent[] = [];
let currentComponent: ParsedIngredientComponent = {
name: "Main",
items: [],
};
for (const line of lines) {
// Check if line is a component header: ends with ":" or starts with "#"
const isHeader = line.endsWith(":") || /^#+\s+/.test(line);
if (isHeader) {
const headerName = line.replace(/^#+\s*/, "").replace(/:$/, "").trim();
if (currentComponent.items.length > 0 || currentComponent.name !== "Main") {
if (currentComponent.items.length > 0) {
components.push(currentComponent);
}
currentComponent = {
name: headerName || "Main",
items: [],
};
} else {
currentComponent.name = headerName || "Main";
}
continue;
}
// Otherwise parse ingredient line
const item = parseSingleIngredientLine(line);
currentComponent.items.push(item);
}
if (currentComponent.items.length > 0) {
components.push(currentComponent);
}
if (components.length === 0) {
throw new Error("The parser did not find any ingredients.");
}
return {
normalized_text: normalizedText,
components,
warnings: [],
};
}
export async function parseIngredientsWithOllama(text: string): Promise<IngredientParseResult> {
// Deterministic culinary parsing (no AI/network dependency)
return parseIngredientsDeterministic(text);
} }
export function validateIngredientParse(value: unknown, normalizedText: string): IngredientParseResult { export function validateIngredientParse(value: unknown, normalizedText: string): IngredientParseResult {
if (!value || typeof value !== "object") throw new Error("The parser returned an invalid document."); if (!value || typeof value !== "object") throw new Error("The parser returned an invalid document.");
const source = value as Record<string, unknown>; const source = value as Record<string, unknown>;
if (!Array.isArray(source.components) || !Array.isArray(source.warnings)) throw new Error("The parser response is missing components or warnings."); if (!Array.isArray(source.components) || !Array.isArray(source.warnings)) {
throw new Error("The parser response is missing components or warnings.");
}
const components = source.components.map((entry) => { const components = source.components.map((entry) => {
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid component."); if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid component.");
const component = entry as Record<string, unknown>; const component = entry as Record<string, unknown>;
if (typeof component.name !== "string" || !component.name.trim() || !Array.isArray(component.items)) throw new Error("The parser returned an invalid component."); if (typeof component.name !== "string" || !component.name.trim() || !Array.isArray(component.items)) {
const items = component.items.map((entry) => { throw new Error("The parser returned an invalid component.");
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid ingredient."); }
const item = entry as Record<string, unknown>; const items = component.items.map((rawItem) => {
if (!rawItem || typeof rawItem !== "object") throw new Error("The parser returned an invalid ingredient.");
const item = rawItem as Record<string, unknown>;
if (typeof item.source_line !== "string") throw new Error("The parser returned an ingredient without its source line."); if (typeof item.source_line !== "string") throw new Error("The parser returned an ingredient without its source line.");
if (typeof item.ingredient !== "string" || !item.ingredient.trim()) throw new Error(`The parser returned an unnamed ingredient for: ${item.source_line}`); if (typeof item.ingredient !== "string" || !item.ingredient.trim()) throw new Error(`The parser returned an unnamed ingredient for: ${item.source_line}`);
if (!(item.quantity === null || typeof item.quantity === "number" && Number.isFinite(item.quantity) && item.quantity > 0)) throw new Error(`The parser returned an invalid quantity for: ${item.source_line}`); if (!(item.quantity === null || (typeof item.quantity === "number" && Number.isFinite(item.quantity) && item.quantity > 0))) {
if (!isNullableString(item.unit) || !isNullableString(item.preparation) || !isNullableString(item.note)) throw new Error(`The parser returned invalid text fields for: ${item.source_line}`); throw new Error(`The parser returned an invalid quantity for: ${item.source_line}`);
if (typeof item.optional !== "boolean" || !Array.isArray(item.alternatives) || !item.alternatives.every((value) => typeof value === "string")) throw new Error(`The parser returned invalid qualifications for: ${item.source_line}`); }
const sourceLine = item.source_line.trim();
const sourceSaysOptional = /\boptional\b/i.test(sourceLine);
const alternatives = (item.alternatives as string[]).map((value) => value.trim()).filter((value) => value && !/^(?:none|optional)$/i.test(value));
const note = item.note?.trim() || null;
return { return {
source_line: sourceLine, source_line: String(item.source_line),
quantity: item.quantity as number | null, quantity: item.quantity as number | null,
unit: item.unit?.trim().toLowerCase() || null, unit: item.unit ? String(item.unit) : null,
ingredient: item.ingredient.trim(), ingredient: String(item.ingredient),
preparation: item.preparation?.trim() || null, preparation: item.preparation ? String(item.preparation) : null,
note: note && (!/^optional$/i.test(note) || sourceSaysOptional) ? note : null, note: item.note ? String(item.note) : null,
optional: sourceSaysOptional, optional: Boolean(item.optional),
alternatives, alternatives: Array.isArray(item.alternatives) ? (item.alternatives as string[]) : [],
} satisfies ParsedIngredient; } satisfies ParsedIngredient;
}); });
return { name: component.name.trim(), items }; return { name: component.name.trim(), items };
}).filter((component) => component.items.length > 0); }).filter((comp) => comp.items.length > 0);
if (!components.length) throw new Error("The parser did not find any ingredients.");
if (!source.warnings.every((value) => typeof value === "string")) throw new Error("The parser returned invalid warnings.");
const consolidated: ParsedIngredientComponent[] = [];
for (const component of components) {
const previous = consolidated.at(-1);
if (previous?.name.toLowerCase() === component.name.toLowerCase()) previous.items.push(...component.items);
else consolidated.push(component);
}
return { normalized_text: normalizedText, components: consolidated, warnings: source.warnings as string[] };
}
export async function parseIngredientsWithOllama(text: string): Promise<IngredientParseResult> { if (!components.length) throw new Error("The parser did not find any ingredients.");
const normalizedText = normalizeIngredientText(text); return { normalized_text: normalizedText, components, warnings: source.warnings as string[] };
if (!normalizedText) throw new Error("Enter at least one ingredient.");
const endpoint = process.env.FORMULATION_OLLAMA_URL ?? "http://10.0.10.211:11434/api/chat";
const model = process.env.FORMULATION_INGREDIENT_PARSER_MODEL ?? "qwen3:4b-instruct";
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
signal: AbortSignal.timeout(90_000),
body: JSON.stringify({
model,
stream: false,
think: false,
format: responseSchema,
options: { temperature: 0, num_predict: 6000 },
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: normalizedText },
],
}),
});
if (!response.ok) throw new Error(`Ingredient parser service failed (${response.status}).`);
const payload = await response.json() as { message?: { content?: string } };
const content = payload.message?.content?.trim();
if (!content) throw new Error("Ingredient parser returned an empty response.");
let parsed: unknown;
const unwrapped = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
const firstBrace = unwrapped.indexOf("{");
const lastBrace = unwrapped.lastIndexOf("}");
const json = firstBrace >= 0 && lastBrace > firstBrace ? unwrapped.slice(firstBrace, lastBrace + 1) : unwrapped;
try { parsed = JSON.parse(json); }
catch { throw new Error("Ingredient parser returned malformed JSON."); }
return validateIngredientParse(parsed, normalizedText);
} }
+5 -2
View File
@@ -117,8 +117,11 @@ p { line-height:1.65; }
.taxonomy-grid { padding-block:50px 90px; display:grid; grid-template-columns:repeat(3,1fr); gap:16px; }.taxonomy-grid a { display:flex; flex-direction:column; gap:8px; padding:25px; border:1px solid var(--line); background:var(--card); text-decoration:none; }.taxonomy-grid span { color:var(--muted); font-size:.85rem; } .taxonomy-grid { padding-block:50px 90px; display:grid; grid-template-columns:repeat(3,1fr); gap:16px; }.taxonomy-grid a { display:flex; flex-direction:column; gap:8px; padding:25px; border:1px solid var(--line); background:var(--card); text-decoration:none; }.taxonomy-grid span { color:var(--muted); font-size:.85rem; }
.tag-cloud { padding-block:50px 90px; display:flex; gap:12px; flex-wrap:wrap; }.tag-cloud a { padding:10px 14px; border:1px solid var(--line); background:var(--card); text-decoration:none; }.tag-cloud small { color:var(--muted); margin-left:4px; } .tag-cloud { padding-block:50px 90px; display:flex; gap:12px; flex-wrap:wrap; }.tag-cloud a { padding:10px 14px; border:1px solid var(--line); background:var(--card); text-decoration:none; }.tag-cloud small { color:var(--muted); margin-left:4px; }
.recipe-page { padding-block:70px 100px; }.recipe-header { display:grid; grid-template-columns:1fr 280px; gap:80px; align-items:end; padding-bottom:55px; border-bottom:1px solid var(--line); }.recipe-header h1 { font-size:clamp(3rem,6vw,5.6rem); margin:18px 0; }.recipe-kicker { display:flex; gap:20px; text-transform:uppercase; color:var(--muted); letter-spacing:.12em; font-size:.7rem; }.recipe-facts { margin:0; display:grid; grid-template-columns:1fr 1fr; border-top:1px solid var(--ink); }.recipe-facts div { padding:17px 5px; border-bottom:1px solid var(--line); }.recipe-facts dt { color:var(--muted); font-size:.7rem; text-transform:uppercase; letter-spacing:.1em; }.recipe-facts dd { margin:5px 0 0; font-weight:600; } .recipe-page { padding-block:70px 100px; }.recipe-header { display:grid; grid-template-columns:1fr 280px; gap:80px; align-items:end; padding-bottom:55px; border-bottom:1px solid var(--line); }.recipe-header h1 { font-size:clamp(3rem,6vw,5.6rem); margin:18px 0; }.recipe-kicker { display:flex; gap:20px; text-transform:uppercase; color:var(--muted); letter-spacing:.12em; font-size:.7rem; }.recipe-facts { margin:0; display:grid; grid-template-columns:1fr 1fr; border-top:1px solid var(--ink); }.recipe-facts div { padding:17px 5px; border-bottom:1px solid var(--line); }.recipe-facts dt { color:var(--muted); font-size:.7rem; text-transform:uppercase; letter-spacing:.1em; }.recipe-facts dd { margin:5px 0 0; font-weight:600; }
.workspace-command-bar { display:flex; align-items:center; gap:.75rem; margin-bottom:2rem; }.workspace-command-bar .back-link { margin-right:auto; }.mode-indicator { padding:.45rem .7rem; color:var(--muted); background:var(--card); border:1px solid var(--line); font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; }.mode-indicator.editing { color:#8b321f; background:#f3dfd4; }.primary-command { padding:.65rem .9rem; color:white; background:var(--green); text-decoration:none; font-weight:700; }.recipe-task-tabs { position:sticky; top:0; z-index:2; display:flex; gap:1.25rem; overflow-x:auto; margin-top:1.5rem; padding:.85rem 0; background:var(--paper); border-bottom:1px solid var(--line); }.recipe-task-tabs a { white-space:nowrap; color:var(--green); text-decoration:none; font-size:.78rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; } .notice { margin-top:28px; padding:16px 20px; border-left:4px solid var(--orange); background:#f3dfd4; position:relative; }
.notice { margin-top:28px; padding:16px 20px; border-left:4px solid var(--orange); background:#f3dfd4; } .success-notice { margin-bottom:1rem; padding:1rem; color:#204f31; background:#dcebdc; border-left:4px solid #39734c; position:relative; }
.notice,.success-notice { display:flex; align-items:center; justify-content:space-between; gap:12px; border-radius:6px; }
.notice-dismiss-btn { display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; padding:0; background:transparent; border:0; color:inherit; font-size:18px; line-height:1; cursor:pointer; opacity:.7; border-radius:4px; transition:opacity .12s ease, background-color .12s ease; }
.notice-dismiss-btn:hover { opacity:1; background:rgba(0,0,0,.06); }
.calculator { margin-top:65px; }.calculator-heading { display:flex; justify-content:space-between; align-items:end; margin-bottom:25px; }.calculator-heading h2 { margin:0; }.basis-input { display:flex; flex-direction:column; gap:7px; font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); }.input-with-unit { display:flex; align-items:center; border-bottom:1px solid var(--ink); color:var(--ink); }.input-with-unit input { width:120px; padding:8px 5px; border:0; background:transparent; font:600 1.1rem var(--sans); color:var(--ink); outline:none; } .calculator { margin-top:65px; }.calculator-heading { display:flex; justify-content:space-between; align-items:end; margin-bottom:25px; }.calculator-heading h2 { margin:0; }.basis-input { display:flex; flex-direction:column; gap:7px; font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); }.input-with-unit { display:flex; align-items:center; border-bottom:1px solid var(--ink); color:var(--ink); }.input-with-unit input { width:120px; padding:8px 5px; border:0; background:transparent; font:600 1.1rem var(--sans); color:var(--ink); outline:none; }
.quantity-control { display:inline-flex; align-items:center; border-bottom:1px solid var(--ink); }.quantity-control input { width:9rem; padding:.55rem .3rem; border:0; background:transparent; color:var(--ink); font:600 1.05rem var(--sans); }.quantity-control select { padding:.55rem .3rem; border:0; background:transparent; color:var(--green); font-weight:700; cursor:pointer; }.line-quantity { display:grid; grid-template-columns:7rem 6rem; justify-content:end; border-bottom:0; }.line-quantity input { width:100%; text-align:right; }.line-quantity select { width:100%; text-align:left; } .quantity-control { display:inline-flex; align-items:center; border-bottom:1px solid var(--ink); }.quantity-control input { width:9rem; padding:.55rem .3rem; border:0; background:transparent; color:var(--ink); font:600 1.05rem var(--sans); }.quantity-control select { padding:.55rem .3rem; border:0; background:transparent; color:var(--green); font-weight:700; cursor:pointer; }.line-quantity { display:grid; grid-template-columns:7rem 6rem; justify-content:end; border-bottom:0; }.line-quantity input { width:100%; text-align:right; }.line-quantity select { width:100%; text-align:left; }
.formula-component { margin-top:30px; }.table-wrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; } th,td { padding:14px 10px; border-bottom:1px solid var(--line); text-align:left; } th:not(:first-child),td:not(:first-child) { text-align:right; } th { color:var(--muted); font-size:.68rem; text-transform:uppercase; letter-spacing:.1em; }.recipe-ingredients { table-layout:fixed; }.recipe-ingredients th:first-child,.recipe-ingredients td:first-child { text-align:left; }.recipe-ingredients th:nth-last-child(2):not(:first-child),.recipe-ingredients td:nth-last-child(2):not(:first-child) { width:8rem; text-align:right; }.recipe-ingredients th:last-child,.recipe-ingredients td:last-child { width:14rem; text-align:right; }.basis-row td:first-child { color:var(--green); font-weight:600; }td small { display:block; color:var(--muted); margin-top:3px; }.muted { color:var(--muted); font-size:.75em; }.formula-total { display:flex; justify-content:flex-end; gap:60px; padding:20px 10px; background:var(--green); color:white; } .formula-component { margin-top:30px; }.table-wrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; } th,td { padding:14px 10px; border-bottom:1px solid var(--line); text-align:left; } th:not(:first-child),td:not(:first-child) { text-align:right; } th { color:var(--muted); font-size:.68rem; text-transform:uppercase; letter-spacing:.1em; }.recipe-ingredients { table-layout:fixed; }.recipe-ingredients th:first-child,.recipe-ingredients td:first-child { text-align:left; }.recipe-ingredients th:nth-last-child(2):not(:first-child),.recipe-ingredients td:nth-last-child(2):not(:first-child) { width:8rem; text-align:right; }.recipe-ingredients th:last-child,.recipe-ingredients td:last-child { width:14rem; text-align:right; }.basis-row td:first-child { color:var(--green); font-weight:600; }td small { display:block; color:var(--muted); margin-top:3px; }.muted { color:var(--muted); font-size:.75em; }.formula-total { display:flex; justify-content:flex-end; gap:60px; padding:20px 10px; background:var(--green); color:white; }
+92 -2
View File
@@ -364,11 +364,49 @@
.application-body .step-body { .application-body .step-body {
min-width: 0; min-width: 0;
} }
.application-body .step-indicator {
display: flex;
align-items: center;
justify-content: center;
min-width: 24px;
padding-top: 4px;
}
.application-body .prep-heading-tag {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
background: #eef2ff;
color: #3d5df6;
font-size: 11px;
font-weight: 700;
}
.application-body .prep-note-tag {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fef3c7;
color: #d97706;
font-size: 14px;
font-weight: 700;
}
.application-body .method-step-card .step-num { .application-body .method-step-card .step-num {
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: #050841; color: #050841;
padding-top: 4px; }
.application-body .method-step-card.prep-heading {
border-left: 3px solid #3d5df6;
background: #fafbfe;
}
.application-body .method-step-card.prep-note {
border-left: 3px solid #f59e0b;
background: #fffdfa;
} }
.application-body .method-step-card .step-textarea { .application-body .method-step-card .step-textarea {
width: 100%; width: 100%;
@@ -783,9 +821,61 @@
.application-body .recipe-detail-shell > .entity-detail-header > div:first-child > p { display:none; } .application-body .recipe-detail-shell > .entity-detail-header > div:first-child > p { display:none; }
.application-body .component-section-block {
margin-bottom: 24px;
}
.application-body .editor-notice-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
padding: 12px 16px;
border-radius: 6px;
}
.application-body .editor-notice-banner .notice-dismiss-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: 0;
color: inherit;
font-size: 18px;
line-height: 1;
cursor: pointer;
opacity: 0.7;
border-radius: 4px;
transition: opacity 0.12s ease, background-color 0.12s ease;
}
.application-body .editor-notice-banner .notice-dismiss-btn:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.06);
}
.application-body .tag-chip-editor { min-height:42px; padding:6px 10px; background:#fff; border:1px solid #dfe3ec; border-radius:4px; } .application-body .tag-chip-editor { min-height:42px; padding:6px 10px; background:#fff; border:1px solid #dfe3ec; border-radius:4px; }
.application-body .tag-chip-list { display:flex; flex-wrap:wrap; gap:6px; } .application-body .tag-chip-list { display:flex; flex-wrap:wrap; gap:6px; }
.application-body .tag-chip-list span { display:inline-flex; align-items:center; gap:5px; min-height:26px; padding:2px 7px 2px 10px; color:#202962; background:#f1f5fe; border-radius:16px; font-size:12px; } .application-body .tag-chip-list span {
display:inline-flex;
align-items:center;
gap:5px;
min-height:26px;
padding:2px 7px 2px 10px;
color:#202962;
background:#f1f5fe;
border-radius:16px;
font-size:12px;
cursor:pointer;
user-select:none;
transition:background 0.12s ease, color 0.12s ease;
}
.application-body .tag-chip-list span:hover {
background:#dbe4ff;
color:#1236e1;
}
.application-body .tag-chip-list b { font-weight:500; } .application-body .tag-chip-list b { font-weight:500; }
.application-body .tag-chip-list button { display:grid; place-items:center; width:18px; height:18px; padding:0; color:#7d86a0; background:transparent; border:0; border-radius:50%; cursor:pointer; font-size:14px; } .application-body .tag-chip-list button { display:grid; place-items:center; width:18px; height:18px; padding:0; color:#7d86a0; background:transparent; border:0; border-radius:50%; cursor:pointer; font-size:14px; }
.application-body .tag-chip-list button:hover { color:#050841; background:#e1e7fa; } .application-body .tag-chip-list button:hover { color:#050841; background:#e1e7fa; }