From 1addddd8fc611bf75916e821f8a403b93f7c3f18 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 19 May 2026 22:46:57 -0400 Subject: [PATCH] feat: add parseISODuration for ISO 8601 duration parsing --- src/Roux/SchemaOrg.hs | 45 ++++++++++++++++++++++++++++++++++++-- test/Roux/SchemaOrgSpec.hs | 14 ++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/Roux/SchemaOrg.hs b/src/Roux/SchemaOrg.hs index d7ed7d5..658d0e3 100644 --- a/src/Roux/SchemaOrg.hs +++ b/src/Roux/SchemaOrg.hs @@ -12,6 +12,7 @@ module Roux.SchemaOrg ( parseISODuration, ) where +import Control.Monad (guard) import Data.Aeson (FromJSON (..), (.!=), (.:), (.:?)) import qualified Data.Aeson as A import qualified Data.Aeson.KeyMap as KM @@ -219,6 +220,46 @@ parseSchemaOrgRecipe val = do schemaOrgToCooklang :: SchemaOrgRecipe -> Either String Recipe schemaOrgToCooklang _ = Left "Not yet implemented" --- | Parse an ISO 8601 duration string. (Stub — will be implemented in Task 2) +{- | Parse an ISO 8601 duration string into a Cooklang Duration. +Supports formats like PT20M, PT1H30M, P1DT2H, P1D. +-} parseISODuration :: Text -> Maybe Duration -parseISODuration _ = Nothing +parseISODuration t + | not ("P" `T.isPrefixOf` t) = Nothing + | otherwise = do + let rest = T.drop 1 t -- drop the P + (datePart, afterDate) = T.break (== 'T') rest + days = parseDateDays datePart + timePart = T.drop 1 afterDate -- drop the T + (hours, minutes, _seconds) = parseTimeComponent timePart + totalMinutes = days * 24 * 60 + hours * 60 + minutes + guard (totalMinutes > 0) + Just (Duration (toRational totalMinutes) (Just "minutes")) + where + parseDateDays :: Text -> Int + parseDateDays dp = + let (numStr, after) = T.break (== 'D') dp + in case T.uncons after of + Just ('D', _) -> fromMaybe 0 (readMaybe (T.unpack numStr)) + _ -> 0 + + parseTimeComponent :: Text -> (Int, Int, Int) + parseTimeComponent tc = + let (hours, rest1) = parseUnit "H" tc + (minutes, rest2) = parseUnit "M" rest1 + (seconds, _rest3) = parseUnit "S" rest2 + in (hours, minutes, seconds) + + parseUnit :: Text -> Text -> (Int, Text) + parseUnit unit tc = + let (numStr, after) = T.break (\c -> c == 'H' || c == 'M' || c == 'S') tc + in case T.uncons after of + Just (c, rest) + | T.singleton c == unit -> + (fromMaybe 0 (readMaybe (T.unpack numStr)), rest) + _ -> (0, tc) + +readMaybe :: (Read a) => String -> Maybe a +readMaybe s = case reads s of + [(x, "")] -> Just x + _ -> Nothing diff --git a/test/Roux/SchemaOrgSpec.hs b/test/Roux/SchemaOrgSpec.hs index 632a8f2..52467bb 100644 --- a/test/Roux/SchemaOrgSpec.hs +++ b/test/Roux/SchemaOrgSpec.hs @@ -1,6 +1,7 @@ module Roux.SchemaOrgSpec (spec) where import qualified Data.Aeson as A +import Data.CookLang (Duration (..)) import Data.Maybe (fromJust, isJust) import Data.Text (Text) import qualified Data.Text as T @@ -49,3 +50,16 @@ spec = describe "SchemaOrg" $ do case recipe of Nothing -> expectationFailure "expected Just SchemaOrgRecipe, got Nothing" Just r -> soName r `shouldBe` "Carrot Risotto With Chile Crisp" + + describe "parseISODuration" $ do + let chk :: Text -> Maybe Duration -> IO () + chk input expected = parseISODuration input `shouldBe` expected + + it "PT20M -> 20 minutes" $ chk "PT20M" (Just (Duration 20 (Just "minutes"))) + it "PT1H -> 60 minutes" $ chk "PT1H" (Just (Duration 60 (Just "minutes"))) + it "PT1H30M -> 90 minutes" $ chk "PT1H30M" (Just (Duration 90 (Just "minutes"))) + it "P1D -> 1440 minutes" $ chk "P1D" (Just (Duration 1440 (Just "minutes"))) + it "P1DT2H -> 1560 minutes" $ chk "P1DT2H" (Just (Duration 1560 (Just "minutes"))) + it "PT30M -> 30 minutes" $ chk "PT30M" (Just (Duration 30 (Just "minutes"))) + it "empty string -> Nothing" $ chk "" Nothing + it "human-readable -> Nothing" $ chk "20 minutes" Nothing