This commit is contained in:
@@ -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 <url>
|
||||||
|
↓
|
||||||
|
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/<filename> 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 <url>` 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/<filename>`.
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user