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