refactor: replace aeson ToJSON/FromJSON with json-fleece schemas
- RecipeSearchEntry: replace genericToJSON ToJSON with Fleece schema using object combinators (required, optionalNullable, list text) - SchemaOrgRecipe: replace manual FromJSON instance with Fleece decoder that reuses existing helper functions for quirky fields - Html.hs: use Fleece.Aeson.Encoder.encode instead of Data.Aeson.encode - stack.yaml: add shrubbery + json-fleece extra-deps - package.yaml: add json-fleece-aeson + json-fleece-core deps - Remove unused LANGUAGE pragmas and redundant brackets
This commit is contained in:
@@ -29,6 +29,8 @@ ghc-options:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- base >= 4.7 && < 5
|
- base >= 4.7 && < 5
|
||||||
- aeson
|
- aeson
|
||||||
|
- json-fleece-aeson
|
||||||
|
- json-fleece-core
|
||||||
- blaze-html
|
- blaze-html
|
||||||
- blaze-markup
|
- blaze-markup
|
||||||
- bytestring
|
- bytestring
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ library
|
|||||||
, directory
|
, directory
|
||||||
, filepath
|
, filepath
|
||||||
, http-types
|
, http-types
|
||||||
|
, json-fleece-aeson
|
||||||
|
, json-fleece-core
|
||||||
, optparse-applicative
|
, optparse-applicative
|
||||||
, parsec
|
, parsec
|
||||||
, text
|
, text
|
||||||
@@ -79,6 +81,8 @@ executable check-recipes
|
|||||||
, directory
|
, directory
|
||||||
, filepath
|
, filepath
|
||||||
, http-types
|
, http-types
|
||||||
|
, json-fleece-aeson
|
||||||
|
, json-fleece-core
|
||||||
, optparse-applicative
|
, optparse-applicative
|
||||||
, parsec
|
, parsec
|
||||||
, roux-server
|
, roux-server
|
||||||
@@ -112,6 +116,8 @@ executable roux-server
|
|||||||
, directory
|
, directory
|
||||||
, filepath
|
, filepath
|
||||||
, http-types
|
, http-types
|
||||||
|
, json-fleece-aeson
|
||||||
|
, json-fleece-core
|
||||||
, optparse-applicative
|
, optparse-applicative
|
||||||
, parsec
|
, parsec
|
||||||
, roux-server
|
, roux-server
|
||||||
@@ -151,6 +157,8 @@ test-suite roux-server-test
|
|||||||
, filepath
|
, filepath
|
||||||
, hspec
|
, hspec
|
||||||
, http-types
|
, http-types
|
||||||
|
, json-fleece-aeson
|
||||||
|
, json-fleece-core
|
||||||
, optparse-applicative
|
, optparse-applicative
|
||||||
, parsec
|
, parsec
|
||||||
, roux-server
|
, roux-server
|
||||||
|
|||||||
+4
-3
@@ -16,7 +16,6 @@ module Roux.Html (
|
|||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Monad (unless)
|
import Control.Monad (unless)
|
||||||
import Data.Aeson (encode)
|
|
||||||
import Data.ByteString.Lazy (ByteString)
|
import Data.ByteString.Lazy (ByteString)
|
||||||
import qualified Data.ByteString.Lazy as LB
|
import qualified Data.ByteString.Lazy as LB
|
||||||
import Data.Function ((&))
|
import Data.Function ((&))
|
||||||
@@ -26,6 +25,8 @@ import Data.Ratio (denominator, numerator)
|
|||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import Data.Text.Encoding (decodeUtf8)
|
import Data.Text.Encoding (decodeUtf8)
|
||||||
|
import qualified Fleece.Aeson as Fleece
|
||||||
|
import qualified Fleece.Core as FC
|
||||||
import qualified Text.Blaze.Html.Renderer.Utf8 as R
|
import qualified Text.Blaze.Html.Renderer.Utf8 as R
|
||||||
import Text.Blaze.Html5 as H
|
import Text.Blaze.Html5 as H
|
||||||
import Text.Blaze.Html5.Attributes as A
|
import Text.Blaze.Html5.Attributes as A
|
||||||
@@ -349,10 +350,10 @@ indexPage _mode recipes =
|
|||||||
H.div ! A.id "roux-recipe-list" $ H.p "Loading recipes..."
|
H.div ! A.id "roux-recipe-list" $ H.p "Loading recipes..."
|
||||||
-- Embedded JSON: recipe data
|
-- Embedded JSON: recipe data
|
||||||
H.script ! A.id "roux-recipe-data" ! A.type_ "application/json" $
|
H.script ! A.id "roux-recipe-data" ! A.type_ "application/json" $
|
||||||
H.toHtml (decodeUtf8 (LB.toStrict (encode (Prelude.map Idx.toSearchEntry (filterOk recipes)))))
|
H.toHtml (decodeUtf8 (LB.toStrict (Fleece.encode (Fleece.encoder (FC.list Idx.recipeSearchEntrySchema)) (Prelude.map Idx.toSearchEntry (filterOk recipes)))))
|
||||||
-- Embedded JSON: error data
|
-- Embedded JSON: error data
|
||||||
H.script ! A.id "roux-errors-data" ! A.type_ "application/json" $
|
H.script ! A.id "roux-errors-data" ! A.type_ "application/json" $
|
||||||
H.toHtml (decodeUtf8 (LB.toStrict (encode (Prelude.map Idx.toSearchEntry (filterBad recipes)))))
|
H.toHtml (decodeUtf8 (LB.toStrict (Fleece.encode (Fleece.encoder (FC.list Idx.recipeSearchEntrySchema)) (Prelude.map Idx.toSearchEntry (filterBad recipes)))))
|
||||||
-- Inline JS
|
-- Inline JS
|
||||||
H.script ! A.type_ "text/javascript" $ H.preEscapedText searchJs
|
H.script ! A.type_ "text/javascript" $ H.preEscapedText searchJs
|
||||||
where
|
where
|
||||||
|
|||||||
+14
-7
@@ -1,17 +1,17 @@
|
|||||||
{-# LANGUAGE DeriveGeneric #-}
|
|
||||||
|
|
||||||
-- | Scanning and indexing of Cooklang recipe files on disk.
|
-- | Scanning and indexing of Cooklang recipe files on disk.
|
||||||
module Roux.RecipeIndex (
|
module Roux.RecipeIndex (
|
||||||
RecipeInfo (..),
|
RecipeInfo (..),
|
||||||
RecipeSearchEntry (..),
|
RecipeSearchEntry (..),
|
||||||
|
recipeSearchEntrySchema,
|
||||||
scanRecipes,
|
scanRecipes,
|
||||||
toSearchEntry,
|
toSearchEntry,
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Data.Aeson (ToJSON (..), camelTo2, defaultOptions, fieldLabelModifier, genericToJSON)
|
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import GHC.Generics (Generic)
|
import Fleece.Aeson (EncoderDecoder)
|
||||||
|
import Fleece.Core (constructor, list, object, optionalNullable, required, text, (#+))
|
||||||
|
import qualified Fleece.Core as FC
|
||||||
import System.Directory (listDirectory)
|
import System.Directory (listDirectory)
|
||||||
import System.FilePath (takeBaseName, takeExtension, (</>))
|
import System.FilePath (takeBaseName, takeExtension, (</>))
|
||||||
|
|
||||||
@@ -58,10 +58,17 @@ data RecipeSearchEntry = RecipeSearchEntry
|
|||||||
, rseCourse :: !(Maybe Text)
|
, rseCourse :: !(Maybe Text)
|
||||||
, rseTags :: ![Text]
|
, rseTags :: ![Text]
|
||||||
}
|
}
|
||||||
deriving stock (Generic)
|
deriving stock (Eq, Show)
|
||||||
|
|
||||||
instance ToJSON RecipeSearchEntry where
|
-- | Fleece schema for encoding 'RecipeSearchEntry' to JSON.
|
||||||
toJSON = genericToJSON defaultOptions{fieldLabelModifier = camelTo2 '_' . drop 3}
|
recipeSearchEntrySchema :: FC.Schema EncoderDecoder RecipeSearchEntry
|
||||||
|
recipeSearchEntrySchema =
|
||||||
|
object $
|
||||||
|
constructor RecipeSearchEntry
|
||||||
|
#+ required "title" rseTitle text
|
||||||
|
#+ required "filename" rseFilename text
|
||||||
|
#+ optionalNullable FC.EmitNull "course" rseCourse text
|
||||||
|
#+ required "tags" rseTags (list text)
|
||||||
|
|
||||||
{- | Convert a 'RecipeInfo' into a 'RecipeSearchEntry', extracting course and
|
{- | Convert a 'RecipeInfo' into a 'RecipeSearchEntry', extracting course and
|
||||||
tags from the parsed recipe metadata when available.
|
tags from the parsed recipe metadata when available.
|
||||||
|
|||||||
+73
-31
@@ -1,3 +1,4 @@
|
|||||||
|
{-# LANGUAGE DataKinds #-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
module Roux.SchemaOrg (
|
module Roux.SchemaOrg (
|
||||||
@@ -12,14 +13,15 @@ module Roux.SchemaOrg (
|
|||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Monad (guard)
|
import Control.Monad (guard)
|
||||||
import Data.Aeson (FromJSON (..), (.!=), (.:), (.:?))
|
|
||||||
import qualified Data.Aeson as A
|
import qualified Data.Aeson as A
|
||||||
import qualified Data.Aeson.KeyMap as KM
|
import qualified Data.Aeson.KeyMap as KM
|
||||||
import Data.Aeson.Types (parseMaybe)
|
|
||||||
import Data.List.NonEmpty (NonEmpty ((:|)), fromList)
|
import Data.List.NonEmpty (NonEmpty ((:|)), fromList)
|
||||||
import Data.Maybe (catMaybes, fromMaybe, mapMaybe)
|
import Data.Maybe (catMaybes, fromMaybe, mapMaybe)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
import qualified Fleece.Aeson as Fleece
|
||||||
|
import qualified Fleece.Aeson.Decoder as FleeceD
|
||||||
|
import qualified Fleece.Core as FC
|
||||||
|
|
||||||
import Data.CookLang
|
import Data.CookLang
|
||||||
|
|
||||||
@@ -179,38 +181,78 @@ lookupText key obj = case KM.lookup key obj of
|
|||||||
-- FromJSON instance
|
-- FromJSON instance
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
instance FromJSON SchemaOrgRecipe where
|
-- | Fleece schema for decoding a schema.org Recipe from JSON-LD.
|
||||||
parseJSON = A.withObject "SchemaOrgRecipe" $ \obj ->
|
schemaOrgRecipeSchema :: FC.Schema Fleece.Decoder SchemaOrgRecipe
|
||||||
SchemaOrgRecipe
|
schemaOrgRecipeSchema =
|
||||||
<$> obj .: "name"
|
FC.Schema
|
||||||
<*> obj .:? "description"
|
{ FC.schemaName = FC.unqualifiedName "SchemaOrgRecipe"
|
||||||
<*> obj .:? "url"
|
, FC.schemaInterpreter = FleeceD.Decoder decodeRecipe
|
||||||
<*> obj .:? "totalTime"
|
}
|
||||||
<*> obj .:? "prepTime"
|
where
|
||||||
<*> obj .:? "cookTime"
|
decodeRecipe val = do
|
||||||
<*> obj .:? "recipeYield"
|
obj <- case val of
|
||||||
<*> pure (parseOptionalTextOrArray obj "recipeCuisine")
|
A.Object o -> pure o
|
||||||
<*> pure (parseOptionalTextOrArray obj "recipeCategory")
|
A.Array arr -> case foldr (:) [] arr of
|
||||||
<*> pure (parseOptionalTextList obj "keywords")
|
(A.Object o : _) -> pure o
|
||||||
<*> pure (parsePerson obj)
|
_ -> fail "Expected object or array of objects"
|
||||||
<*> pure (parseImages obj "image")
|
_ -> fail "Expected JSON object"
|
||||||
<*> obj .:? "datePublished"
|
case KM.lookup "@type" obj of
|
||||||
<*> pure (parseNutrition obj)
|
Just (A.String "Recipe") -> pure ()
|
||||||
<*> (obj .:? "recipeIngredient" .!= [])
|
_ -> fail "Not a schema.org Recipe"
|
||||||
<*> pure (parseHowToSteps obj)
|
name <- requiredText "name" obj
|
||||||
|
description <- optionalText "description" obj
|
||||||
|
url <- optionalText "url" obj
|
||||||
|
totalTime <- optionalText "totalTime" obj
|
||||||
|
prepTime <- optionalText "prepTime" obj
|
||||||
|
cookTime <- optionalText "cookTime" obj
|
||||||
|
recipeYield <- optionalText "recipeYield" obj
|
||||||
|
let recipeCuisine = parseOptionalTextOrArray obj "recipeCuisine"
|
||||||
|
recipeCategory = parseOptionalTextOrArray obj "recipeCategory"
|
||||||
|
keywords = parseOptionalTextList obj "keywords"
|
||||||
|
author = parsePerson obj
|
||||||
|
image = parseImages obj "image"
|
||||||
|
nutrition = parseNutrition obj
|
||||||
|
ingsVal = KM.lookup "recipeIngredient" obj
|
||||||
|
ingredients = case ingsVal of
|
||||||
|
Just (A.Array arr) -> mapMaybe textFromValue (foldr (:) [] arr)
|
||||||
|
Just (A.String _) -> []
|
||||||
|
_ -> []
|
||||||
|
instructions = parseHowToSteps obj
|
||||||
|
datePublished <- optionalText "datePublished" obj
|
||||||
|
pure
|
||||||
|
SchemaOrgRecipe
|
||||||
|
{ soName = name
|
||||||
|
, soDescription = description
|
||||||
|
, soUrl = url
|
||||||
|
, soTotalTime = totalTime
|
||||||
|
, soPrepTime = prepTime
|
||||||
|
, soCookTime = cookTime
|
||||||
|
, soRecipeYield = recipeYield
|
||||||
|
, soRecipeCuisine = recipeCuisine
|
||||||
|
, soRecipeCategory = recipeCategory
|
||||||
|
, soKeywords = keywords
|
||||||
|
, soAuthor = author
|
||||||
|
, soImage = image
|
||||||
|
, soDatePublished = datePublished
|
||||||
|
, soNutrition = nutrition
|
||||||
|
, soRecipeIngredient = ingredients
|
||||||
|
, soRecipeInstructions = instructions
|
||||||
|
}
|
||||||
|
requiredText key obj = case KM.lookup key obj of
|
||||||
|
Just (A.String t) -> pure t
|
||||||
|
Just _ -> fail "Expected string for field"
|
||||||
|
Nothing -> fail "Missing required field"
|
||||||
|
optionalText key obj = case KM.lookup key obj of
|
||||||
|
Just (A.String t) -> pure (Just t)
|
||||||
|
_ -> pure Nothing
|
||||||
|
textFromValue (A.String t) = Just t
|
||||||
|
textFromValue _ = Nothing
|
||||||
|
|
||||||
-- | Parse a schema.org Recipe from a JSON Value, handling @type selection.
|
-- | Parse a schema.org Recipe from a JSON Value, handling @type selection.
|
||||||
parseSchemaOrgRecipe :: A.Value -> Maybe SchemaOrgRecipe
|
parseSchemaOrgRecipe :: A.Value -> Maybe SchemaOrgRecipe
|
||||||
parseSchemaOrgRecipe val = do
|
parseSchemaOrgRecipe val = case FleeceD.fromValue schemaOrgRecipeSchema val of
|
||||||
obj <- case val of
|
Right r -> Just r
|
||||||
A.Object o -> Just o
|
Left _ -> Nothing
|
||||||
A.Array arr -> case foldr (:) [] arr of
|
|
||||||
(A.Object o : _) -> Just o
|
|
||||||
_ -> Nothing
|
|
||||||
_ -> Nothing
|
|
||||||
case KM.lookup "@type" obj of
|
|
||||||
Just (A.String "Recipe") -> parseMaybe parseJSON (A.Object obj)
|
|
||||||
_ -> Nothing
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Conversion to Cooklang Recipe
|
-- Conversion to Cooklang Recipe
|
||||||
|
|||||||
+11
@@ -2,3 +2,14 @@ resolver: lts-24.38
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
- .
|
- .
|
||||||
|
|
||||||
|
extra-deps:
|
||||||
|
- github: flipstone/shrubbery
|
||||||
|
commit: 1847db5c280357ff341b5909250981b8c303f444
|
||||||
|
subdirs:
|
||||||
|
- .
|
||||||
|
- github: flipstone/json-fleece
|
||||||
|
commit: f2df040dee441a25dd1bbdc8bcf939bf04c74875
|
||||||
|
subdirs:
|
||||||
|
- json-fleece-core
|
||||||
|
- json-fleece-aeson
|
||||||
|
|||||||
+40
-1
@@ -3,7 +3,46 @@
|
|||||||
# For more information, please see the documentation at:
|
# For more information, please see the documentation at:
|
||||||
# https://docs.haskellstack.org/en/stable/topics/lock_files
|
# https://docs.haskellstack.org/en/stable/topics/lock_files
|
||||||
|
|
||||||
packages: []
|
packages:
|
||||||
|
- completed:
|
||||||
|
name: shrubbery
|
||||||
|
pantry-tree:
|
||||||
|
sha256: e9a7f795ed04b76955303132edcce72210bd646c6c9e1ee5dd930a63f2533aab
|
||||||
|
size: 2924
|
||||||
|
sha256: 77d0c3be3bb32ae48297881f937d7bb72a50423a9ebe27e962fc7bfb9c980b00
|
||||||
|
size: 30242
|
||||||
|
subdir: .
|
||||||
|
url: https://github.com/flipstone/shrubbery/archive/1847db5c280357ff341b5909250981b8c303f444.tar.gz
|
||||||
|
version: 0.2.3.2
|
||||||
|
original:
|
||||||
|
subdir: .
|
||||||
|
url: https://github.com/flipstone/shrubbery/archive/1847db5c280357ff341b5909250981b8c303f444.tar.gz
|
||||||
|
- completed:
|
||||||
|
name: json-fleece-core
|
||||||
|
pantry-tree:
|
||||||
|
sha256: f17d0fe31bb3503b0f4566f256cc040e797800871544b26b3f7a4ecd75128022
|
||||||
|
size: 491
|
||||||
|
sha256: 0af34870fde6550c5be93bec6e471c27c7f358d796e5817ae284b37269f527a1
|
||||||
|
size: 3079817
|
||||||
|
subdir: json-fleece-core
|
||||||
|
url: https://github.com/flipstone/json-fleece/archive/f2df040dee441a25dd1bbdc8bcf939bf04c74875.tar.gz
|
||||||
|
version: 0.11.0.1
|
||||||
|
original:
|
||||||
|
subdir: json-fleece-core
|
||||||
|
url: https://github.com/flipstone/json-fleece/archive/f2df040dee441a25dd1bbdc8bcf939bf04c74875.tar.gz
|
||||||
|
- completed:
|
||||||
|
name: json-fleece-aeson
|
||||||
|
pantry-tree:
|
||||||
|
sha256: c1204bd17604272fdcdff26ea9e7b932c3bd311acaad307835a37eb3038566f5
|
||||||
|
size: 628
|
||||||
|
sha256: 0af34870fde6550c5be93bec6e471c27c7f358d796e5817ae284b37269f527a1
|
||||||
|
size: 3079817
|
||||||
|
subdir: json-fleece-aeson
|
||||||
|
url: https://github.com/flipstone/json-fleece/archive/f2df040dee441a25dd1bbdc8bcf939bf04c74875.tar.gz
|
||||||
|
version: 0.5.0.0
|
||||||
|
original:
|
||||||
|
subdir: json-fleece-aeson
|
||||||
|
url: https://github.com/flipstone/json-fleece/archive/f2df040dee441a25dd1bbdc8bcf939bf04c74875.tar.gz
|
||||||
snapshots:
|
snapshots:
|
||||||
- completed:
|
- completed:
|
||||||
sha256: abc790b571e0c70e929db74b329e3c18d7e76a6e173e8bdf94f1ba20770d4c24
|
sha256: abc790b571e0c70e929db74b329e3c18d7e76a6e173e8bdf94f1ba20770d4c24
|
||||||
|
|||||||
Reference in New Issue
Block a user