From 751a9f853c34be6ab553b405061816e1abd8c2dc Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Wed, 20 May 2026 12:55:21 -0400 Subject: [PATCH] docs: add recipe import design spec --- docs/specs/2026-05-20-recipe-import-design.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/specs/2026-05-20-recipe-import-design.md diff --git a/docs/specs/2026-05-20-recipe-import-design.md b/docs/specs/2026-05-20-recipe-import-design.md new file mode 100644 index 0000000..5b6cf00 --- /dev/null +++ b/docs/specs/2026-05-20-recipe-import-design.md @@ -0,0 +1,181 @@ +# Recipe Import via Web Form + +**Date:** 2026-05-20 +**Status:** Approved + +## Problem + +There is no way to import a recipe from a URL. A user who finds a recipe on +the web must manually transcribe it into Cooklang format and save it to the +recipes directory. The `scrape-recipe` script and `SchemaOrg` module already +exist but have no web-facing integration. + +## Goal + +Add a web form at `/import` where a user can paste a recipe URL. The server +scrapes the URL, converts the schema.org JSON-LD to Cooklang, and writes the +resulting `.cook` file to the recipe directory — all with useful error +feedback at each step. + +## Data Flow + +``` +User submits URL + ↓ +Server spawns: scripts/scrape-recipe --pretty + ↓ +Captures stdout (JSON-LD) and stderr (error messages) + ↓ +parseSchemaOrgRecipe parses JSON-LD → SchemaOrgRecipe + ↓ +schemaOrgToCooklang converts → Data.CookLang.Recipe + ↓ +New CooklangPrint module renders Recipe → Cooklang text + ↓ +Write .cook file to recipe directory + ↓ +Redirect to /recipes/ on success, +or render error page with details on failure +``` + +## New Module: `Roux.CooklangPrint` + +A renderer for `Data.CookLang.Recipe` → Cooklang text. Currently there is no +way to serialize a parsed Recipe back to disk. + +### Interface + +```haskell +module Roux.CooklangPrint (renderRecipe, filenameFromTitle) where + +renderRecipe :: Recipe -> Text +-- Produces the full .cook file text with YAML front matter. + +filenameFromTitle :: Text -> FilePath +-- Converts a recipe title to a safe filename (e.g. "Fried Rice" → "fried-rice.cook"). +``` + +### Rendering logic + +The `.cook` file format: + +``` +--- +title: {{title}} +tags: {{tags}} +source: {{source}} +description: {{description}} +--- + +Step 1 text with @ingredient{1%unit} and #cookware{} and ~{5%minutes}. + +== Method == + +Step 2 text... +``` + +**Front matter** — only includes metadata fields that are present: +- `title` (if non-empty — always present for imported recipes) +- `tags` — comma-separated, if non-empty +- `source` — the original URL +- `description` +- `author` +- `course` (recipeCategory) +- `cuisine` +- `servings` — e.g. `4 servings` +- `totalTime`, `prepTime`, `cookTime` — as human-readable minutes + +**Sections** — unnamed sections are rendered without a header; named sections +get `== Name ==` as a header on its own line. + +**Steps within a section** — separated by one blank line. + +**StepItems** — rendered back to Cooklang syntax: + +| StepItem | Cooklang output | Example | +|----------|----------------|---------| +| `StepText t` | plain text | `cook the ` | +| `StepIngredient i` | `@name{amount%unit}` | `@chicken{1%kg}` | +| `StepCookware c` | `#name{}` | `#pot{}` | +| `StepTimer t` | `~{amount%unit}` | `~{30%minutes}` | +| `StepBreak` | `\\` + newline | `\\\n` | +| `StepComment t` | `[- t -]` | `[- optional -]` | +| `StepEndComment t` | ` -- t` | ` -- to taste` | + +Quantity/unit rendering matches existing `Html.showQuantity` patterns. + +## Server Route: `GET /import` and `POST /import` + +**`GET /import`** — renders the import form page with a URL text input and +submit button, matching the existing Pico CSS styling. + +**`POST /import`** — handles the full import pipeline: + +1. Read `url` parameter from the POST body (URL-encoded form data). +2. Validate: non-empty URL. Fail early with a form validation error. +3. Run `scripts/scrape-recipe --pretty ` as a subprocess via + `System.Process.readProcessWithExitCode`. + - **Non-zero exit code:** render error page showing the stderr output + ("Could not scrape URL: {error}"). +4. Parse stdout with `parseSchemaOrgRecipe`. + - **JSON parse failure:** render error page with the raw JSON preview + (truncated) for debugging ("Could not parse recipe data: {reason}"). +5. Convert with `schemaOrgToCooklang`. + - **Conversion failure:** render error page with the message. +6. Render to Cooklang text with `CooklangPrint.renderRecipe`. +7. Derive filename from the recipe title: `filenameFromTitle`. +8. Write to recipe directory via `BS.writeFile`. + - **IO error:** render error page with the exception message. +9. If the file already exists, **overwrite** it (the latest scrape is authoritative). +10. Redirect (303) to `/recipes/`. + +The error pages are specific to each failure mode so the user knows exactly +what went wrong. + +### URL routing + +Add `["import"]` to both GET and POST dispatch in the `router`. GET renders +the form; POST processes the submission. Request method is checked via +`Wai.requestMethod`. + +## Error Feedback Matrix + +| Step | Failure mode | User sees | +|------|-------------|-----------| +| Subprocess | Script not found, timeout, HTTP error from scraper | "Could not scrape URL: {stderr}" with the URL shown | +| JSON parse | Page has no schema.org Recipe data | "Could not parse recipe data: {error}" with truncated JSON preview | +| Cooklang conversion | Missing required fields | "Could not convert recipe: {message}" | +| File write | Permission denied, disk full | "Could not save recipe: {IO error}" | + +A generic catch-all handler wraps the entire POST handler so any unexpected +exception also produces a friendly error page. + +## Files Changed + +### New + +- `src/Roux/CooklangPrint.hs` — `renderRecipe` and `filenameFromTitle` functions + +### Modified + +| File | Change | +|------|--------| +| `src/Roux/Server.hs` | Add `/import` route (GET form, POST submission), `System.Process` call | +| `src/Roux/Html.hs` | Add `importPage` (form), `importResultPage` (success/error pages) | +| `src/Roux.hs` | Re-export `CooklangPrint` types | +| `package.yaml` | Add `process` dependency to library dependencies | + +### `package.yaml` + +Add `process` to library dependencies list. The `process` package is in +LTS-24.38 (bundled with GHC) — no extra-dep needed. + +## Testing + +- **Unit test:** `CooklangPrint.renderRecipe` round-trips the test recipes + (parse an existing `.cook` file → render → parse again → equal) +- **Manual:** Visit `/import`, enter a known working recipe URL, verify the + recipe appears in the index with correct metadata +- **Manual:** Enter an invalid URL, verify a useful error page +- **Manual:** Enter a URL without schema.org data, verify a parse error page +- **Manual:** Enter a URL with partial data, verify graceful handling