# Recipe Import Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a web form at `/import` where a user can paste a recipe URL, converting it from schema.org JSON-LD to a `.cook` file via the existing `scrape-recipe` script and `SchemaOrg` module. **Architecture:** A new `Roux.CooklangPrint` module renders `Recipe` → Cooklang text. The server adds `GET /import` (form) and `POST /import` (submission) routes. The POST handler runs `scripts/scrape-recipe` as a subprocess, parses the JSON, converts to Cooklang, renders to text, and writes the `.cook` file — with error-specific feedback at each step. **Tech Stack:** Haskell, WAI (body parsing), `process` package (subprocess), `text` for rendering. --- ## File Map | File | Action | Purpose | |---|---|---| | `src/Roux/CooklangPrint.hs` | **Create** | Render `Recipe` → Cooklang `.cook` file text | | `src/Roux/Html.hs` | Modify | Add import form page and result/error page templates | | `src/Roux/Server.hs` | Modify | Add `/import` route, subprocess call, file write | | `src/Roux.hs` | Modify | Re-export `CooklangPrint` module | | `package.yaml` | Modify | Add `process` dependency | --- ### Task 1: Create `Roux.CooklangPrint` module **Files:** - Create: `src/Roux/CooklangPrint.hs` This module contains two functions: - `renderRecipe :: Recipe -> Text` — serializes a full `Recipe` to Cooklang text - `filenameFromTitle :: Text -> FilePath` — e.g. `"Fried Rice"` → `"fried-rice.cook"` - [ ] **Step 1: Create the module with front matter rendering** Write `src/Roux/CooklangPrint.hs`: ```haskell {-# LANGUAGE OverloadedStrings #-} {- | Render a 'Recipe' to Cooklang (.cook) file text. Produces the standard Cooklang file format with YAML front matter, section headers, and step text with inline markup. -} module Roux.CooklangPrint ( renderRecipe, filenameFromTitle, ) where import Data.Char (isAlphaNum, toLower) import Data.Function ((&)) import Data.List.NonEmpty (toList) import Data.Maybe (catMaybes) import Data.Ratio (denominator, numerator) import Data.Text (Text) import qualified Data.Text as T import Data.CookLang -- | Render a full Recipe to Cooklang file text. renderRecipe :: Recipe -> Text renderRecipe recipe = T.unlines $ catMaybes [renderFrontMatter meta, renderBody recipe] where meta = recipeMetadata recipe -- | Render YAML front matter for metadata fields that are present. renderFrontMatter :: Metadata -> Maybe Text renderFrontMatter meta = let fields = catMaybes [ field "title" (metaTitle meta) , field "source" (metaSource meta) , field "description" (metaDescription meta) , field "author" (metaAuthor meta) , field "course" (metaCourse meta) , field "cuisine" (metaCuisine meta) , field "servings" (renderServings <$> metaServings meta) , field "totalTime" (renderDurationText <$> metaTotalTime meta) , field "prepTime" (renderDurationText <$> metaPrepTime meta) , field "cookTime" (renderDurationText <$> metaCookTime meta) , field "tags" (renderTagList (metaTags meta)) ] in case fields of [] -> Nothing _ -> Just $ T.unlines $ "---" : fields ++ ["---"] where field :: Text -> Maybe Text -> Maybe Text field _ Nothing = Nothing field key (Just val) = Just $ key <> ": " <> val -- | Render servings like "4 servings". renderServings :: (Int, Maybe Text) -> Text renderServings (n, Nothing) = T.pack (show n) renderServings (n, Just unit) = T.pack (show n) <> " " <> unit -- | Render a duration as human-readable text like "30 minutes". renderDurationText :: Duration -> Text renderDurationText d = let amt = durationAmount d amount = case denominator amt of 1 -> T.pack (show (numerator amt)) _ -> T.pack (show (fromRational amt :: Double)) unit = maybe "" id (durationUnit d) in T.strip (amount <> " " <> unit) -- | Render a tag list as a comma-separated string. renderTagList :: [Text] -> Text renderTagList [] = T.empty renderTagList tags = T.intercalate ", " tags ``` - [ ] **Step 2: Add body rendering functions** Add after `renderTagList`: ```haskell -- | Render the recipe body (sections, steps, inline items). renderBody :: Recipe -> Maybe Text renderBody recipe = let sections = toList (recipeSections recipe) rendered = map renderSection sections body = T.intercalate "\n" rendered in if T.null body then Nothing else Just body -- | Render a single section. renderSection :: Section -> Text renderSection section = let header = case sectionName section of Just name -> "== " <> name <> " ==\n" Nothing -> T.empty items = toList (sectionBody section) steps = map renderBodyItem items in header <> T.intercalate "\n" steps -- | Render a single section body item (step, comment, or note). renderBodyItem :: SectionBodyItem -> Text renderBodyItem (SecStep step) = renderStep step renderBodyItem (SecComment t) = "-- " <> t renderBodyItem (SecNote t) = "> " <> t -- | Render a single step (a paragraph of Cooklang text). renderStep :: Step -> Text renderStep step = T.concatMap renderStepItem (unStep step) -- | Render a single inline step item back to Cooklang syntax. renderStepItem :: StepItem -> Text renderStepItem (StepText t) = t renderStepItem (StepIngredient ing) = "@" <> ingName ing <> renderQuantity (ingQuantity ing) <> renderPreparation (ingPreparation ing) renderStepItem (StepCookware cw) = "#" <> cwName cw <> "{}" renderStepItem (StepTimer timer) = case timerName timer of Just name -> "~" <> name <> "{" <> renderDuration (timerDuration timer) <> "}" Nothing -> "~{" <> renderDuration (timerDuration timer) <> "}" renderStepItem (StepRecipeRef ref) = "@" <> refPath ref <> renderQuantity (refQuantity ref) renderStepItem (StepEndComment t) = " -- " <> t renderStepItem (StepComment t) = "[-" <> t <> "-]" renderStepItem StepBreak = "\\\n" -- | Render an optional quantity to Cooklang syntax. renderQuantity :: Maybe Quantity -> Text renderQuantity Nothing = T.empty renderQuantity (Just q) = let prefix = if quantityFixed q then "=" else "" amount = renderRational (quantityAmount q) unit = case quantityUnit q of Just u -> "%" <> u Nothing -> T.empty in "{" <> prefix <> amount <> unit <> "}" -- | Render an optional preparation to Cooklang syntax (parenthesized). renderPreparation :: Maybe Text -> Text renderPreparation Nothing = T.empty renderPreparation (Just prep) = "(" <> prep <> ")" -- | Render a rational number as a string (e.g. "1/2", "3"). renderRational :: Rational -> Text renderRational r = let n = numerator r d = denominator r in if d == 1 then T.pack (show n) else T.pack (show n <> "/" <> show d) -- | Render a Duration to the Cooklang timer format (e.g. "30%minutes"). renderDuration :: Duration -> Text renderDuration d = let amt = durationAmount d amount = case denominator amt of 1 -> T.pack (show (numerator amt)) _ -> T.pack (show (numerator amt) <> "/" <> show (denominator amt)) unit = case durationUnit d of Just u -> "%" <> u Nothing -> T.empty in amount <> unit -- | Convert a recipe title into a safe filename (e.g. "Fried Rice" → "fried-rice.cook"). filenameFromTitle :: Text -> FilePath filenameFromTitle title = let slug = title & T.toLower & T.map (\c -> if c == ' ' then '-' else c) & T.filter (\c -> isAlphaNum c || c == '-') in T.unpack slug <> ".cook" ``` - [ ] **Step 3: Build to verify compilation** Run: `./hs stack build` Expected: compiles without errors. - [ ] **Step 4: Run tests to verify nothing broke** Run: `./hs stack test` Expected: all tests pass. - [ ] **Step 5: Commit** ```bash git add src/Roux/CooklangPrint.hs git commit -m "feat: add CooklangPrint module for rendering Recipe to .cook text" ``` --- ### Task 2: Add import page templates to `Html.hs` **Files:** - Modify: `src/Roux/Html.hs` - [ ] **Step 1: Add `ImportError` type and update exports** Find the module header in `src/Roux/Html.hs`: ```haskell module Roux.Html ( SortMode (..), indexPage, recipePage, urlEncode, urlDecode, ) where ``` Change to: ```haskell module Roux.Html ( SortMode (..), ImportError (..), importPage, importResultPage, indexPage, recipePage, urlEncode, urlDecode, ) where ``` Add the `ImportError` type near the top of the module, after the `SortMode` data type: ```haskell -- | Error type for the import pipeline. data ImportError = ImportError Text deriving stock (Eq, Show) ``` - [ ] **Step 2: Add the import form and result pages** Add after the `indexPage` function: ```haskell -- | Render the import form page, optionally with a validation error. importPage :: Maybe Text -> ByteString importPage merror = page "Roux — Import Recipe" $ do H.div ! A.class_ "roux-navbar" $ do H.ul $ H.li $ H.a ! A.href "/" $ "← Back" H.ul $ H.li $ H.strong "Import Recipe" case merror of Just err -> H.p ! A.style "color: var(--roux-accent);" $ H.toHtml err Nothing -> pure () H.form ! A.method "POST" ! A.action "/import" $ do H.label ! A.for "url" $ "Recipe URL" H.input ! A.type_ "url" ! A.id "url" ! A.name "url" ! A.placeholder "https://cooking.nytimes.com/recipes/..." ! A.required "" ! A.style "width: 100%; margin-bottom: 1rem;" H.button ! A.type_ "submit" $ "Import Recipe" -- | Render the import result page with an error message. importResultPage :: ImportError -> ByteString importResultPage (ImportError msg) = page "Roux — Import Error" $ do H.div ! A.class_ "roux-navbar" $ do H.ul $ H.li $ H.a ! A.href "/" $ "← Back" H.ul $ H.li $ H.a ! A.href "/import" $ "Try again" H.h2 "Import failed" H.p $ H.toHtml msg ``` - [ ] **Step 3: Build to verify compilation** Run: `./hs stack build` Expected: compiles without errors. - [ ] **Step 4: Run tests to verify nothing broke** Run: `./hs stack test` Expected: all tests pass. - [ ] **Step 5: Commit** ```bash git add src/Roux/Html.hs git commit -m "feat: add import form and result page templates" ``` --- ### Task 3: Add import route to `Server.hs` **Files:** - Modify: `src/Roux/Server.hs` - [ ] **Step 1: Add new imports** Add to the import block in `src/Roux/Server.hs`: ```haskell import qualified Data.Aeson as A import qualified Data.ByteString as BS import Data.Maybe (mapMaybe) import qualified Roux.CooklangPrint as CooklangPrint import Roux.SchemaOrg (parseSchemaOrgRecipe, schemaOrgToCooklang, SchemaOrgRecipe (..)) import System.Exit (ExitCode (..)) import System.Process (readProcessWithExitCode) ``` Note: Check existing imports first and avoid duplication. `Data.ByteString.Lazy (ByteString)` and `qualified Data.ByteString.Lazy as LB` are already present. - [ ] **Step 2: Thread `absDir` through the router** Change `router` signature and implementation. Replace: ```haskell router :: IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> Wai.Application router state changeLog request respond = case Wai.pathInfo request of ["events"] -> sseHandler changeLog request respond _ -> handleRequest state request respond ``` with: ```haskell 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 ``` And in `app`, change: ```haskell pure (router state changeLog) ``` to: ```haskell pure (router absDir state changeLog) ``` - [ ] **Step 3: Add the `readRequestBody` helper** Add near the top of the file, after the imports: ```haskell -- | 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.requestBody req if BS.null chunk then pure (reverse acc) else gather (chunk : acc) ``` - [ ] **Step 4: Add `importHandler` and `parseFormBody`** Add after `router` (before `watchRecipes`): ```haskell {- | 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/" <> T.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.replace "+" " " . T.filter (/= '\r') ``` - [ ] **Step 5: Add `runImportPipeline`** Add after `importHandler`: ```haskell -- | 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 let jsonBytes = LB.pack (map (fromIntegral . fromEnum) 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 ``` - [ ] **Step 6: Build to verify compilation** Run: `./hs stack build` Expected: compiles without errors. - [ ] **Step 7: Run tests to verify nothing broke** Run: `./hs stack test` Expected: all tests pass. - [ ] **Step 8: Commit** ```bash git add src/Roux/Server.hs git commit -m "feat: add /import route for recipe URL import" ``` --- ### Task 4: Wire up dependencies and re-exports **Files:** - Modify: `package.yaml`, `src/Roux.hs` - [ ] **Step 1: Add `process` to `package.yaml`** In `package.yaml`, find the library dependencies list and add `process`: ```yaml - parsec - process - text ``` - [ ] **Step 2: Regenerate `.cabal` file** Run: `./hs hpack` Expected: no errors. - [ ] **Step 3: Add `CooklangPrint` to `Roux.hs` re-exports** In `src/Roux.hs`, add `Roux.CooklangPrint` to the re-exports: ```haskell module Roux ( app, module X, ) where import Data.CookLang as X import Roux.CooklangPrint as X import Roux.SchemaOrg as X import Roux.Server (app) ``` - [ ] **Step 4: Build to verify compilation** Run: `./hs stack build` Expected: compiles without errors. - [ ] **Step 5: Run tests to verify nothing broke** Run: `./hs stack test` Expected: all tests pass. - [ ] **Step 6: Commit** ```bash git add package.yaml roux-server.cabal src/Roux.hs git commit -m "chore: add process dependency and re-export CooklangPrint" ``` --- ### Task 5: End-to-end smoke test **Files:** (none, manual testing) - [ ] **Step 1: Start the server** ```bash cd /work/personal/roux/roux-main mkdir -p /tmp/roux-test-recipes ./hs stack exec roux-server -- --recipe-dir /tmp/roux-test-recipes & sleep 2 ``` - [ ] **Step 2: Verify the import form loads** ```bash curl -s http://localhost:8080/import | grep "Import Recipe" ``` Expected: page contains "Import Recipe" heading and a URL input. - [ ] **Step 3: Submit an empty URL (form validation)** ```bash curl -s -o /dev/null -w "%{http_code}" -X POST -d "url=" http://localhost:8080/import ``` Expected: HTTP 200 with the form page showing a validation error. - [ ] **Step 4: Submit an invalid URL** ```bash curl -s http://localhost:8080/import -X POST -d "url=https://example.com/not-a-recipe" | grep "Import failed" ``` Expected: HTTP 200 with an import error page (the scraper won't find schema.org data). - [ ] **Step 5: Kill the server** ```bash kill %1 2>/dev/null; wait 2>/dev/null ```