feat: add /import route for recipe URL import
This commit is contained in:
@@ -41,6 +41,7 @@ dependencies:
|
|||||||
- http-types
|
- http-types
|
||||||
- optparse-applicative
|
- optparse-applicative
|
||||||
- parsec
|
- parsec
|
||||||
|
- process
|
||||||
- text
|
- text
|
||||||
- time
|
- time
|
||||||
- wai
|
- wai
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ library
|
|||||||
exposed-modules:
|
exposed-modules:
|
||||||
Data.CookLang
|
Data.CookLang
|
||||||
Roux
|
Roux
|
||||||
|
Roux.CooklangPrint
|
||||||
Roux.Html
|
Roux.Html
|
||||||
Roux.NYTimes
|
Roux.NYTimes
|
||||||
Roux.Parser
|
Roux.Parser
|
||||||
@@ -52,6 +53,7 @@ library
|
|||||||
, json-fleece-core
|
, json-fleece-core
|
||||||
, optparse-applicative
|
, optparse-applicative
|
||||||
, parsec
|
, parsec
|
||||||
|
, process
|
||||||
, text
|
, text
|
||||||
, time
|
, time
|
||||||
, wai
|
, wai
|
||||||
@@ -87,6 +89,7 @@ executable check-recipes
|
|||||||
, json-fleece-core
|
, json-fleece-core
|
||||||
, optparse-applicative
|
, optparse-applicative
|
||||||
, parsec
|
, parsec
|
||||||
|
, process
|
||||||
, roux-server
|
, roux-server
|
||||||
, text
|
, text
|
||||||
, time
|
, time
|
||||||
@@ -123,6 +126,7 @@ executable roux-server
|
|||||||
, json-fleece-core
|
, json-fleece-core
|
||||||
, optparse-applicative
|
, optparse-applicative
|
||||||
, parsec
|
, parsec
|
||||||
|
, process
|
||||||
, roux-server
|
, roux-server
|
||||||
, text
|
, text
|
||||||
, time
|
, time
|
||||||
@@ -165,6 +169,7 @@ test-suite roux-server-test
|
|||||||
, json-fleece-core
|
, json-fleece-core
|
||||||
, optparse-applicative
|
, optparse-applicative
|
||||||
, parsec
|
, parsec
|
||||||
|
, process
|
||||||
, roux-server
|
, roux-server
|
||||||
, tasty
|
, tasty
|
||||||
, tasty-golden
|
, tasty-golden
|
||||||
|
|||||||
+120
-4
@@ -41,8 +41,28 @@ import System.FSNotify (
|
|||||||
)
|
)
|
||||||
import System.FilePath (makeRelative, (</>))
|
import System.FilePath (makeRelative, (</>))
|
||||||
|
|
||||||
|
import qualified Data.Aeson as A
|
||||||
|
import qualified Data.ByteString as BS
|
||||||
|
import Data.Maybe (mapMaybe)
|
||||||
|
import qualified Data.Text.Encoding as TE
|
||||||
|
import qualified Roux.CooklangPrint as CooklangPrint
|
||||||
import qualified Roux.Html as Html
|
import qualified Roux.Html as Html
|
||||||
import qualified Roux.RecipeIndex as Idx
|
import qualified Roux.RecipeIndex as Idx
|
||||||
|
import Roux.SchemaOrg (SchemaOrgRecipe (..), parseSchemaOrgRecipe, schemaOrgToCooklang)
|
||||||
|
import System.Exit (ExitCode (..))
|
||||||
|
import System.Process (readProcessWithExitCode)
|
||||||
|
|
||||||
|
-- | Read the full request body as a lazy ByteString.
|
||||||
|
readRequestBody :: Wai.Request -> IO LB.ByteString
|
||||||
|
readRequestBody req = do
|
||||||
|
chunks <- gather []
|
||||||
|
pure (LB.fromChunks chunks)
|
||||||
|
where
|
||||||
|
gather acc = do
|
||||||
|
chunk <- Wai.getRequestBodyChunk req
|
||||||
|
if BS.null chunk
|
||||||
|
then pure (reverse acc)
|
||||||
|
else gather (chunk : acc)
|
||||||
|
|
||||||
{- | Build the WAI 'Application' given a path to the recipe directory.
|
{- | Build the WAI 'Application' given a path to the recipe directory.
|
||||||
|
|
||||||
@@ -71,7 +91,7 @@ app recipeDir = do
|
|||||||
forkIO $
|
forkIO $
|
||||||
watchRecipes absDir state changeLog `catch` \e ->
|
watchRecipes absDir state changeLog `catch` \e ->
|
||||||
putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException)
|
putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException)
|
||||||
pure (router state changeLog)
|
pure (router absDir state changeLog)
|
||||||
where
|
where
|
||||||
isRight (Right _) = True
|
isRight (Right _) = True
|
||||||
isRight (Left _) = False
|
isRight (Left _) = False
|
||||||
@@ -152,13 +172,109 @@ sseHandler changeLog _request respond = do
|
|||||||
sendPing
|
sendPing
|
||||||
go `finally` pure ()
|
go `finally` pure ()
|
||||||
|
|
||||||
-- | Route requests — SSE endpoint or recipe pages.
|
-- | Route requests — SSE endpoint, recipe pages, or import.
|
||||||
router :: IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> Wai.Application
|
router :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> Wai.Application
|
||||||
router state changeLog request respond =
|
router recipeDir state changeLog request respond =
|
||||||
case Wai.pathInfo request of
|
case Wai.pathInfo request of
|
||||||
["events"] -> sseHandler changeLog request respond
|
["events"] -> sseHandler changeLog request respond
|
||||||
|
["import"] -> importHandler recipeDir state request respond
|
||||||
_ -> handleRequest state request respond
|
_ -> handleRequest state request respond
|
||||||
|
|
||||||
|
{- | Handle GET and POST requests to /import.
|
||||||
|
GET — show the import form.
|
||||||
|
POST — scrape the URL, convert to Cooklang, save the file.
|
||||||
|
-}
|
||||||
|
importHandler :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application
|
||||||
|
importHandler recipeDir _state request respond =
|
||||||
|
case Wai.requestMethod request of
|
||||||
|
"GET" ->
|
||||||
|
respond (htmlResponse (Html.importPage Nothing))
|
||||||
|
"POST" -> do
|
||||||
|
body <- readRequestBody request
|
||||||
|
let params = parseFormBody body
|
||||||
|
url = maybe "" id (lookup "url" params)
|
||||||
|
if T.null url
|
||||||
|
then respond (htmlResponse (Html.importPage (Just ("URL is required" :: Text))))
|
||||||
|
else do
|
||||||
|
result <- try $ runImportPipeline recipeDir url
|
||||||
|
case result of
|
||||||
|
Left (e :: SomeException) ->
|
||||||
|
respond $
|
||||||
|
htmlResponse $
|
||||||
|
Html.importResultPage $
|
||||||
|
Html.ImportError ("Unexpected error: " <> T.pack (show e))
|
||||||
|
Right (Left err) ->
|
||||||
|
respond $
|
||||||
|
htmlResponse $
|
||||||
|
Html.importResultPage err
|
||||||
|
Right (Right filename) ->
|
||||||
|
respond $
|
||||||
|
Wai.responseLBS
|
||||||
|
HTTP.status303
|
||||||
|
[("Location", "/recipes/" <> TE.encodeUtf8 (T.pack filename))]
|
||||||
|
""
|
||||||
|
_ -> respond notFound
|
||||||
|
|
||||||
|
-- | Parse URL-encoded form body into key-value pairs.
|
||||||
|
parseFormBody :: LB.ByteString -> [(Text, Text)]
|
||||||
|
parseFormBody body =
|
||||||
|
let decoded = TE.decodeUtf8 $ LB.toStrict body
|
||||||
|
parts = T.splitOn "&" decoded
|
||||||
|
in mapMaybe parsePair parts
|
||||||
|
where
|
||||||
|
parsePair part = case T.splitOn "=" part of
|
||||||
|
[key, val] -> Just (urldecode key, urldecode val)
|
||||||
|
_ -> Nothing
|
||||||
|
urldecode = T.pack . Html.urlDecode . T.replace "+" " "
|
||||||
|
|
||||||
|
-- | Run the full import pipeline: scrape -> parse -> convert -> render -> save.
|
||||||
|
runImportPipeline :: FilePath -> Text -> IO (Either Html.ImportError FilePath)
|
||||||
|
runImportPipeline recipeDir url = do
|
||||||
|
-- Step 1: scrape the URL
|
||||||
|
(exitCode, stdout, stderr) <-
|
||||||
|
readProcessWithExitCode
|
||||||
|
"scripts/scrape-recipe"
|
||||||
|
["--pretty", T.unpack url]
|
||||||
|
""
|
||||||
|
case exitCode of
|
||||||
|
ExitFailure _ ->
|
||||||
|
return $
|
||||||
|
Left $
|
||||||
|
Html.ImportError $
|
||||||
|
"Could not scrape URL. The scraper exited with: " <> T.pack stderr
|
||||||
|
ExitSuccess -> do
|
||||||
|
-- Step 2: decode stdout (String) as JSON Value
|
||||||
|
-- stdout from readProcessWithExitCode is a locale-decoded String;
|
||||||
|
-- re-encode to UTF-8 for JSON decoding.
|
||||||
|
let jsonBytes = LB.fromStrict (TE.encodeUtf8 (T.pack stdout))
|
||||||
|
jsonVal = A.decode jsonBytes :: Maybe A.Value
|
||||||
|
case jsonVal of
|
||||||
|
Nothing ->
|
||||||
|
return $
|
||||||
|
Left $
|
||||||
|
Html.ImportError $
|
||||||
|
"Could not parse JSON from scraper output."
|
||||||
|
Just val -> case parseSchemaOrgRecipe val of
|
||||||
|
Nothing ->
|
||||||
|
return $
|
||||||
|
Left $
|
||||||
|
Html.ImportError $
|
||||||
|
"Could not parse recipe data. The page may not have schema.org recipe data."
|
||||||
|
Just schema -> case schemaOrgToCooklang schema of
|
||||||
|
Left err ->
|
||||||
|
return $
|
||||||
|
Left $
|
||||||
|
Html.ImportError $
|
||||||
|
"Could not convert recipe: " <> T.pack err
|
||||||
|
Right recipe -> do
|
||||||
|
-- Step 3: render to Cooklang text
|
||||||
|
let cookText = CooklangPrint.renderRecipe recipe
|
||||||
|
filename = CooklangPrint.filenameFromTitle (soName schema)
|
||||||
|
filepath = recipeDir </> filename
|
||||||
|
-- Step 4: write the .cook file
|
||||||
|
BS.writeFile filepath (TE.encodeUtf8 cookText)
|
||||||
|
return $ Right filename
|
||||||
|
|
||||||
{- | Watch a directory for .cook file changes and update the shared state.
|
{- | Watch a directory for .cook file changes and update the shared state.
|
||||||
Uses watchDir callback for simplicity.
|
Uses watchDir callback for simplicity.
|
||||||
-}
|
-}
|
||||||
|
|||||||
Reference in New Issue
Block a user