feat: parse schema.org ingredient strings into structured Cooklang ingredients
Build and Deploy / build-and-deploy (push) Successful in 1m45s

Replaces buildIngredientSection's plain-text dump with proper
StepIngredient annotation. Parses ingredient strings like
"3/4 cup extra-virgin olive oil" into:
  - quantity: 3/4
  - unit: "cup"
  - name: "extra-virgin olive oil"

Handles fractions ("3/4"), compound quantities ("1,1/2"),
unit recognition (cup, ounce, pound, etc.), and strips
preparation notes after the first comma.
This commit is contained in:
2026-05-20 18:11:30 -04:00
parent d29d8b0af0
commit bf6fde376a
+135 -3
View File
@@ -295,13 +295,145 @@ buildSections r =
(s : ss) -> s :| ss
[] -> Section Nothing (SecStep (Step []) :| []) :| []
-- | Known units of measure for ingredient parsing.
ingredientUnits :: [Text]
ingredientUnits =
[ "cup"
, "cups"
, "tablespoon"
, "tablespoons"
, "tbsp"
, "teaspoon"
, "teaspoons"
, "tsp"
, "ounce"
, "ounces"
, "oz"
, "pound"
, "pounds"
, "lb"
, "lbs"
, "gram"
, "grams"
, "g"
, "kilogram"
, "kilograms"
, "kg"
, "milliliter"
, "milliliters"
, "ml"
, "liter"
, "liters"
, "l"
, "pinch"
, "pinches"
, "clove"
, "cloves"
, "slice"
, "slices"
, "piece"
, "pieces"
, "can"
, "cans"
, "package"
, "packages"
, "bunch"
, "bunches"
, "sprig"
, "sprigs"
, "leaf"
, "leaves"
, "item"
, "items"
, "inch"
, "inches"
]
{- | Parse a schema.org ingredient string into a StepIngredient.
Handles formats like:
"3/4 cup extra-virgin olive oil"
"2 eggs"
"Salt and black pepper"
"1 large lemon"
"1 pound cherry tomatoes, halved"
-}
parseSchemaOrgIngredient :: Text -> StepItem
parseSchemaOrgIngredient t =
let cleaned = T.strip t
-- Strip preparation notes after the first comma
mainPart = fst (T.breakOn "," cleaned)
toks = T.words mainPart
(parsedQty, remainingWords) = extractQuantity toks
name = T.strip $ T.intercalate " " remainingWords
in StepIngredient (Ingredient name parsedQty Nothing)
where
-- Extract quantity and optional unit from the front of the word list.
extractQuantity :: [Text] -> (Maybe Quantity, [Text])
extractQuantity (w : ws) =
case parseHumanRational w of
Just amt ->
-- Try to consume a unit word next
case ws of
(u : rest)
| T.toLower u `elem` ingredientUnits ->
(Just (Quantity amt (Just u) False), rest)
_ ->
(Just (Quantity amt Nothing False), ws)
Nothing -> (Nothing, w : ws) -- didn't start with a number
extractQuantity [] = (Nothing, [])
{- | Parse a human-readable quantity (number or fraction) from text.
Handles "3/4", "2", "1,1/2", "0.5".
-}
parseHumanRational :: Text -> Maybe Rational
parseHumanRational t =
case T.splitOn "," cleaned of
-- Compound: "1,1/2" → 1 + 1/2 = 3/2
[a, b] -> (+) <$> parseSimpleRational a <*> parseSimpleRational b
-- Simple: "1/2" or "3"
[a] -> parseSimpleRational a
_ -> Nothing
where
cleaned = T.strip t
parseSimpleRational :: Text -> Maybe Rational
parseSimpleRational t =
case T.splitOn "/" t of
-- Fraction: "1/2"
[num, den] ->
case (readInt (num), readInt (den)) of
(Just n, Just d) | d /= 0 -> Just (toRational n / toRational d)
_ -> Nothing
-- Whole number or decimal: "3", "0.5", or range like "14-18"
[a] ->
case T.splitOn "-" a of
[first, _rest] -> parseNumeric (T.strip first)
_ -> parseNumeric a
_ -> Nothing
where
readInt :: Text -> Maybe Int
readInt x = case reads (T.unpack x) of [(n, "")] -> Just n; _ -> Nothing
parseNumeric :: Text -> Maybe Rational
parseNumeric t =
case reads (T.unpack t) of
[(n, "")] -> Just (toRational (n :: Int))
_ -> case reads (T.unpack t) of
[(d, "")] -> Just (toRational (d :: Double))
_ -> Nothing
buildIngredientSection :: [Text] -> Section
buildIngredientSection ings
| null ings = Section Nothing (SecStep (Step []) :| [])
| otherwise =
let items = concatMap (\t -> [StepText ", ", StepText t]) ings
step = Step items
in Section (Just "Ingredients") (SecStep step :| [])
let
-- Parse each ingredient string into a StepIngredient
parsed = map parseSchemaOrgIngredient ings
-- Interleave with comma separators for nice formatting
items = concatMap (\i -> [StepText ", ", i]) parsed
step = Step (drop 2 items) -- drop leading ", "
in
Section (Just "Ingredients") (SecStep step :| [])
buildMethodSection :: [SchemaOrgHowToStep] -> Maybe Section
buildMethodSection [] = Nothing