diff --git a/docs/plans/2026-05-20-recipe-import.md b/docs/plans/2026-05-20-recipe-import.md new file mode 100644 index 0000000..2a88657 --- /dev/null +++ b/docs/plans/2026-05-20-recipe-import.md @@ -0,0 +1,608 @@ +# 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 +``` diff --git a/src/Roux/Html.hs b/src/Roux/Html.hs index cc3afb8..d7a9dcc 100644 --- a/src/Roux/Html.hs +++ b/src/Roux/Html.hs @@ -219,7 +219,11 @@ page title content = , ".roux-step:has(input:checked) .step-text { opacity: 0.4; }" , "" , ".roux-course { font-size: 0.6rem; color: var(--roux-accent); opacity: 0.65; margin: 0 0 0.15rem; letter-spacing: 0.04em; text-transform: uppercase; font-weight: 500; }" - , ".roux-desc { font-size: 0.85rem; line-height: 1.6; color: var(--roux-text); margin: 0 0 1.5rem; max-width: 640px; opacity: 0.85; }" + , ".roux-desc { font-size: 0.85rem; line-height: 1.6; color: var(--roux-text); margin: 0; max-width: 640px; opacity: 0.85; }" + , ".roux-desc-row { display: flex; gap: 2rem; align-items: flex-start; margin-bottom: 1.5rem; }" + , ".roux-desc-row .roux-desc { flex: 1; }" + , ".roux-desc-row img { max-width: 280px; height: auto; border-radius: 6px; box-shadow: 0 2px 8px rgba(61, 44, 30, 0.1); }" + , "@media (max-width: 768px) { .roux-desc-row { flex-direction: column; } .roux-desc-row img { max-width: 100%; } }" , "" , ".roux-ingredient-tag { border-bottom: 1.5px solid rgba(191, 148, 40, 0.35); }" , ".roux-cookware-tag, .roux-timer-tag { opacity: 0.85; }" @@ -535,9 +539,13 @@ renderRecipe recipe = do Just url -> H.a ! A.class_ "roux-source-link" ! A.href (H.toValue url) ! A.target "_blank" ! A.rel "noopener noreferrer" $ do "↗ Original" Nothing -> pure () - case desc of - Just d -> H.p ! A.class_ "roux-desc" $ H.toHtml d - Nothing -> pure () + H.div ! A.class_ "roux-desc-row" $ do + case desc of + Just d -> H.p ! A.class_ "roux-desc" $ H.toHtml d + Nothing -> pure () + case metaImage meta of + Just img -> H.img ! A.src (H.toValue img) ! A.alt (H.toValue (titleText recipe)) + Nothing -> pure () renderMetaBar meta let sections = NE.toList (recipeSections recipe) notes = collectNotes recipe