feat: add /import route for recipe URL import

This commit is contained in:
2026-05-20 13:17:48 -04:00
parent 4738972046
commit 6e895f7286
3 changed files with 126 additions and 4 deletions
+1
View File
@@ -41,6 +41,7 @@ dependencies:
- http-types
- optparse-applicative
- parsec
- process
- text
- time
- wai
+5
View File
@@ -18,6 +18,7 @@ library
exposed-modules:
Data.CookLang
Roux
Roux.CooklangPrint
Roux.Html
Roux.NYTimes
Roux.Parser
@@ -52,6 +53,7 @@ library
, json-fleece-core
, optparse-applicative
, parsec
, process
, text
, time
, wai
@@ -87,6 +89,7 @@ executable check-recipes
, json-fleece-core
, optparse-applicative
, parsec
, process
, roux-server
, text
, time
@@ -123,6 +126,7 @@ executable roux-server
, json-fleece-core
, optparse-applicative
, parsec
, process
, roux-server
, text
, time
@@ -165,6 +169,7 @@ test-suite roux-server-test
, json-fleece-core
, optparse-applicative
, parsec
, process
, roux-server
, tasty
, tasty-golden
+120 -4
View File
@@ -41,8 +41,28 @@ import System.FSNotify (
)
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.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.
@@ -71,7 +91,7 @@ app recipeDir = do
forkIO $
watchRecipes absDir state changeLog `catch` \e ->
putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException)
pure (router state changeLog)
pure (router absDir state changeLog)
where
isRight (Right _) = True
isRight (Left _) = False
@@ -152,13 +172,109 @@ sseHandler changeLog _request respond = do
sendPing
go `finally` pure ()
-- | Route requests — SSE endpoint or recipe pages.
router :: IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> Wai.Application
router state changeLog request respond =
-- | Route requests — SSE endpoint, recipe pages, or import.
router :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> Wai.Application
router recipeDir state changeLog request respond =
case Wai.pathInfo request of
["events"] -> sseHandler changeLog request respond
["import"] -> importHandler recipeDir 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.
Uses watchDir callback for simplicity.
-}