feat: parse schema.org ingredient strings into structured Cooklang ingredients
Build and Deploy / build-and-deploy (push) Successful in 1m45s
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:
+135
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user