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 { parseIngredientsWithOllama } from "../../../../../lib/ingredient-parser";
import { parseIngredientsDeterministic } from "../../../../../lib/ingredient-parser";
import { readOnlyMode } from "../../../../../lib/runtime";
export const prerender = false;
@@ -10,7 +10,7 @@ export const POST: APIRoute = async ({ request }) => {
const body = await request.json() as { text?: unknown };
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 });
return Response.json(await parseIngredientsWithOllama(body.text));
return Response.json(parseIngredientsDeterministic(body.text));
} catch (error) {
const message = error instanceof Error && error.name === "TimeoutError"
? "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');
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);
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()};
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">
<form method="post" id="recipe-details-form" class="editor-form recipe-overview-form">
<input type="hidden" name="save_version" value={recipe.save_version} />
{saved && <div class="success-notice">Changes saved.</div>}
{error && <div class="notice">{error}</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"><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}]}>
<span class="yield-title-label">Total Yield</span>
<div class="yield-inputs-row">
@@ -264,7 +264,6 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
</select>
<input name="storage_condition" value={shelfLife?.storage_condition??""} placeholder="Storage condition"/>
</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>
</form>
</section>
@@ -444,7 +443,6 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
<section class="recipe-additional-view">
<div>
<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>}
{JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>}
</div>
@@ -476,7 +474,7 @@ const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
{editing&&<script is:inline>
let recipeDirty=false;
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.querySelector('#recipe-details-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,
focusedItem,
setFocusedItem,
draftNotes,
setDraftNotes,
bulkOpen,
setBulkOpen,
bulkText,
@@ -46,6 +48,7 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
methodTarget,
state,
message,
setMessage,
dragging,
setDragging,
calculatePercent,
@@ -105,7 +108,17 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
</div>
{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 && (
@@ -116,436 +129,425 @@ export default function RecipeStructureEditor(props: RecipeEditorProps) {
</p>
)}
<div class="table-wrap">
<table
class={`editor-items${
calculatePercent && percentMode === "bakers" ? " bakers-mode" : ""
}`}
>
<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>
)}
<div class="components-container">
{data.components.map((component, componentIndex) => {
const componentId = component.id;
const currentQuery = query[componentId] ?? "";
const currentDraftNote = draftNotes[componentId] ?? "";
{(component.notes ?? []).map((note, noteIndex) => (
<tr
class="component-note-row"
key={`note-${component.id}-${noteIndex}`}
>
<td
colSpan={
calculatePercent
? percentMode === "bakers"
? 7
: 6
: 5
}
>
<div class="component-note-content">
<input
class="component-notes"
aria-label={`${component.name} note ${
noteIndex + 1
}`}
value={note}
placeholder="Add section note"
onInput={(event) =>
updateComponent(componentIndex, (value) => ({
...value,
notes: (value.notes ?? []).map((entry, index) =>
index === noteIndex
? event.currentTarget.value
: entry
return (
<div class="component-section-block" key={component.id}>
<div class="table-wrap">
<table
class={`editor-items${
calculatePercent && percentMode === "bakers"
? " bakers-mode"
: ""
}`}
>
{componentIndex === 0 && (
<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.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
),
}))
}
/>
<button
type="button"
class="remove-row"
aria-label="Remove note"
onClick={() =>
updateComponent(componentIndex, (value) => ({
...value,
notes: (value.notes ?? []).filter(
(_, index) => index !== noteIndex
),
}))
}));
setDragging(undefined);
}}
>
<td
colSpan={
calculatePercent
? percentMode === "bakers"
? 7
: 6
: 5
}
>
</button>
</div>
</td>
</tr>
))}
<div class="component-header-content">
<input
class="component-name-input"
aria-label="Component name"
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) => (
<RecipeItemRow
key={item.id}
item={item}
componentIndex={componentIndex}
itemIndex={itemIndex}
units={units}
ingredients={ingredients}
pendingIngredients={pendingIngredients}
recipes={recipes}
recipeId={recipeId}
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,
});
{(component.notes ?? []).map((note, noteIndex) => (
<tr
class="component-note-row"
key={`note-${component.id}-${noteIndex}`}
>
<td
colSpan={
calculatePercent
? percentMode === "bakers"
? 7
: 6
: 5
}
>
<div class="component-note-content">
<input
class="component-notes"
aria-label={`${component.name} note ${
noteIndex + 1
}`}
value={note}
placeholder="Add section note"
onInput={(event) =>
updateComponent(componentIndex, (value) => ({
...value,
notes: (value.notes ?? []).map((entry, index) =>
index === noteIndex
? event.currentTarget.value
: entry
),
}))
}
/>
<button
type="button"
class="remove-row"
aria-label="Remove note"
onClick={() =>
updateComponent(componentIndex, (value) => ({
...value,
notes: (value.notes ?? []).filter(
(_, index) => index !== noteIndex
),
}))
}
>
</button>
</div>
</td>
</tr>
))}
{component.items.map((item, itemIndex) => (
<RecipeItemRow
key={item.id}
item={item}
componentIndex={componentIndex}
itemIndex={itemIndex}
units={units}
ingredients={ingredients}
pendingIngredients={pendingIngredients}
recipes={recipes}
recipeId={recipeId}
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) => {
event.stopPropagation();
if (
dragging?.kind === "item" &&
dragging.component === componentIndex
) {
updateComponent(componentIndex, (value) => ({
...value,
items: move(
value.items,
dragging.index,
itemIndex
),
}));
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
const val = (query[componentId] ?? "").trim();
if (val) {
createPendingIngredient(componentIndex, val);
setQuery((current) => ({ ...current, [componentId]: "" }));
}
}
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)
}
/>
))}
</Fragment>
))}
</tbody>
</table>
</div>
<div class="quick-add-bar">
<div class="quick-add-search">
<input
value={
query[
data.components[data.components.length - 1]?.id ??
data.components[0]?.id ??
""
] ?? ""
}
placeholder="1cup onion sliced"
aria-label="Add ingredient or recipe"
onInput={(event) => {
const value = event.currentTarget.value;
const activeId =
data.components[data.components.length - 1]?.id ??
data.components[0]?.id;
if (activeId)
setQuery((current) => ({ ...current, [activeId]: value }));
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
const activeIndex = data.components.length - 1;
const activeId =
data.components[activeIndex]?.id ??
data.components[0]?.id;
const val = (query[activeId ?? ""] ?? "").trim();
if (val) {
createPendingIngredient(activeIndex, val);
setQuery((current) => ({ ...current, [activeId]: "" }));
}
}
}}
/>
{Boolean(
(
query[
data.components[data.components.length - 1]?.id ??
data.components[0]?.id ??
""
] ?? ""
).trim()
) && (
<div class="recipe-item-results">
{[
...[...ingredients, ...pendingIngredients].map((item) => ({
...item,
kind: "ingredient" as const,
})),
...recipes
.filter((item) => item.id !== recipeId)
.map((item) => ({ ...item, kind: "recipe" as const })),
]
.filter((item) => {
const activeId =
data.components[data.components.length - 1]?.id ??
data.components[0]?.id ??
"";
const q = (query[activeId] ?? "").trim().toLowerCase();
return [item.name, ...(item.aliases ?? [])].some((name) =>
name.toLowerCase().includes(q)
);
})
.slice(0, 8)
.map((item) => (
<button
type="button"
key={`${item.kind}-${item.id}`}
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]: "" }));
{Boolean(currentQuery.trim()) && (
<div class="recipe-item-results">
{[
...[...ingredients, ...pendingIngredients].map((item) => ({
...item,
kind: "ingredient" as const,
})),
...recipes
.filter((item) => item.id !== recipeId)
.map((item) => ({ ...item, kind: "recipe" as const })),
]
.filter((item) => {
const q = currentQuery.trim().toLowerCase();
return [item.name, ...(item.aliases ?? [])].some((name) =>
name.toLowerCase().includes(q)
);
})
.slice(0, 8)
.map((item) => (
<button
type="button"
key={`${item.kind}-${item.id}`}
onClick={() => {
addLine(componentIndex, `${item.kind}:${item.id}`);
setQuery((current) => ({ ...current, [componentId]: "" }));
}}
>
<strong>{item.name}</strong>
<small>
{item.kind === "recipe" ? "Recipe" : "Ingredient"}
</small>
</button>
))}
{(() => {
const q = currentQuery.trim();
if (
q &&
!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={() => {
createPendingIngredient(componentIndex, q);
setQuery((current) => ({
...current,
[componentId]: "",
}));
}}
>
<strong>+ Create {q}</strong>
<small>New ingredient</small>
</button>
);
}
return null;
})()}
</div>
)}
</div>
<div class="quick-add-notes">
<input
value={currentDraftNote}
placeholder="Add notes"
aria-label="Add notes"
onInput={(event) => {
const val = event.currentTarget.value;
setDraftNotes((current) => ({
...current,
[componentId]: val,
}));
}}
>
<strong>{item.name}</strong>
<small>
{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>
<div class="quick-add-notes">
<input placeholder="Add notes" aria-label="Add notes" />
</div>
);
})}
</div>
<div class="structure-bottom-bar">
@@ -23,18 +23,37 @@ export default function RecipeMethodEditor({
onDrop,
onOpenBulkPrep,
}: Props) {
let stepCount = 0;
const content = (
<section class="method-editor">
<div class="method-header-row">
<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>
</div>
<ol class="method-steps-list">
{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"
: /^\(.+\)$/.test(step.instruction.trim())
: isNote
? " prep-note"
: "";
@@ -44,19 +63,37 @@ export default function RecipeMethodEditor({
dragging?.kind === "step" && dragging.index === index
? " dragging"
: ""
}${stepKind}`}
}${stepKindClass}`}
key={step.id}
onDragOver={(event) => event.preventDefault()}
onDrop={() => {
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">
<textarea
rows={2}
rows={isHeading ? 1 : 2}
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}
onInput={(event) =>
onStepsChange(
@@ -117,6 +154,21 @@ export default function RecipeMethodEditor({
})}
</ol>
<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
type="button"
class="action-link-btn"
@@ -453,11 +453,24 @@ export function useRecipeStructure({
setPrepError("Field is required");
return;
}
const additions = lines.map((instruction) => ({
id: uid("step"),
instruction,
equipment_ids: [],
}));
const additions = lines.map((rawLine) => {
let instruction = rawLine.trim();
const isHeader = instruction.endsWith(":") || /^#+\s+/.test(instruction);
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) => ({
...current,
steps:
@@ -654,6 +667,7 @@ export function useRecipeStructure({
baseline,
state,
message,
setMessage,
dragging,
setDragging,
calculatePercent,
+11
View File
@@ -59,6 +59,17 @@ const isApplication = Astro.url.pathname.startsWith("/app") || Astro.url.pathnam
menus().forEach((menu) => menu.addEventListener("toggle", () => {
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>}
</body>
+54 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { normalizeIngredientText, validateIngredientParse } from "./ingredient-parser";
import {
normalizeIngredientText,
parseIngredientsDeterministic,
validateIngredientParse,
} from "./ingredient-parser";
describe("ingredient parser", () => {
it("normalizes copied checklist text and Unicode fractions", () => {
@@ -24,4 +28,53 @@ describe("ingredient parser", () => {
}] }], warnings:[],
}, "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 {
return value
.replace(/[¼½¾⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞]/g, (value) => FRACTIONS[value] ?? value)
.replace(/[¼½¾⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞]/g, (match) => FRACTIONS[match] ?? match)
.normalize("NFKC")
.replace(//g, "/")
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "")
@@ -44,143 +44,242 @@ export function normalizeIngredientText(value: string): string {
.join("\n");
}
const responseSchema = {
type: "object",
additionalProperties: false,
required: ["components", "warnings"],
properties: {
components: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["name", "items"],
properties: {
name: { type: "string" },
items: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["source_line", "quantity", "unit", "ingredient", "preparation", "note", "optional", "alternatives"],
properties: {
source_line: { type: "string" },
quantity: { type: ["number", "null"] },
unit: { type: ["string", "null"] },
ingredient: { type: "string" },
preparation: { type: ["string", "null"] },
note: { type: ["string", "null"] },
optional: { type: "boolean" },
alternatives: { type: "array", items: { type: "string" } },
},
},
},
},
},
},
warnings: { type: "array", items: { type: "string" } },
},
} as const;
function parseFractionValue(raw: string): number | null {
const trimmed = raw.trim();
if (!trimmed) return null;
// Handle range like "2-3" or "2 - 3"
if (/^\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?$/.test(trimmed)) {
const [low, high] = trimmed.split("-").map((v) => Number(v.trim()));
return (low + high) / 2;
}
// Handle mixed fraction like "1 1/2" or "1-1/2"
const mixed = trimmed.match(/^(\d+)\s+([0-9]+)\/([0-9]+)$/);
if (mixed) {
const whole = Number(mixed[1]);
const num = Number(mixed[2]);
const den = Number(mixed[3]);
if (den === 0) return null;
return whole + num / den;
}
// Handle simple fraction like "1/2"
const frac = trimmed.match(/^([0-9]+)\/([0-9]+)$/);
if (frac) {
const num = Number(frac[1]);
const den = Number(frac[2]);
if (den === 0) return null;
return num / den;
}
// Handle decimal or integer
const num = Number(trimmed);
return Number.isFinite(num) && num > 0 ? num : null;
}
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:
- Preserve the meaning and never invent an ingredient, amount, unit, or preparation.
- Convert fractions and mixed numbers to decimal quantities.
- 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.`;
export function parseSingleIngredientLine(sourceLine: string): ParsedIngredient {
const line = sourceLine.trim();
const optional = /\boptional\b/i.test(line);
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string";
// Check notes in parentheses
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 {
if (!value || typeof value !== "object") throw new Error("The parser returned an invalid document.");
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) => {
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid component.");
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.");
const items = component.items.map((entry) => {
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid ingredient.");
const item = 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.");
}
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.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 (!isNullableString(item.unit) || !isNullableString(item.preparation) || !isNullableString(item.note)) throw new Error(`The parser returned invalid text fields 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;
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}`);
}
return {
source_line: sourceLine,
source_line: String(item.source_line),
quantity: item.quantity as number | null,
unit: item.unit?.trim().toLowerCase() || null,
ingredient: item.ingredient.trim(),
preparation: item.preparation?.trim() || null,
note: note && (!/^optional$/i.test(note) || sourceSaysOptional) ? note : null,
optional: sourceSaysOptional,
alternatives,
unit: item.unit ? String(item.unit) : null,
ingredient: String(item.ingredient),
preparation: item.preparation ? String(item.preparation) : null,
note: item.note ? String(item.note) : null,
optional: Boolean(item.optional),
alternatives: Array.isArray(item.alternatives) ? (item.alternatives as string[]) : [],
} satisfies ParsedIngredient;
});
return { name: component.name.trim(), items };
}).filter((component) => component.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[] };
}
}).filter((comp) => comp.items.length > 0);
export async function parseIngredientsWithOllama(text: string): Promise<IngredientParseResult> {
const normalizedText = normalizeIngredientText(text);
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);
if (!components.length) throw new Error("The parser did not find any ingredients.");
return { normalized_text: normalizedText, components, warnings: source.warnings as string[] };
}
+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; }
.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; }
.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; }
.notice { margin-top:28px; padding:16px 20px; border-left:4px solid var(--orange); background:#f3dfd4; position:relative; }
.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; }
.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; }
+92 -2
View File
@@ -364,11 +364,49 @@
.application-body .step-body {
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 {
font-size: 14px;
font-weight: 700;
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 {
width: 100%;
@@ -783,9 +821,61 @@
.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-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 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; }