5b9afa53ff
Captures the non-obvious bits of Mealie's REST API: two-step create+patch flow, food/unit id resolution (parser returns id:null for unknown names), locale seeding to stop the parser fuzzy-matching teaspoon→tablespoon, required ingredientReferences:[] on instructions, and the family meal-type tagging convention (Breakfast/Lunch/Dinner/Dessert). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
145 lines
7.5 KiB
Markdown
145 lines
7.5 KiB
Markdown
---
|
|
name: mealie
|
|
description: Adding and editing recipes in the family Mealie instance, including the non-obvious ingredient-parsing requirements
|
|
---
|
|
|
|
<instance>
|
|
- URL: https://meals.vino.network (Mealie v3.18.x, OIDC via Authentik)
|
|
- Hosted in sjc001 cluster, `mealie` namespace
|
|
- Family group/household: `Home` / `Family`
|
|
- API token: stored in `code/git/code.fritzlab.net/fritzlab/agent/.env` as
|
|
`MEALIE_TOKEN` (paired with `MEALIE_URL=https://meals.vino.network`).
|
|
Regenerate at https://meals.vino.network/user/profile/api-tokens.
|
|
- All endpoints take `Authorization: Bearer $MEALIE_TOKEN`.
|
|
</instance>
|
|
|
|
<conventions>
|
|
- **Every recipe must carry exactly one meal-type tag**: `Breakfast`, `Lunch`, `Dinner`,
|
|
or `Dessert`. When importing or editing a recipe, add the appropriate one; preserve
|
|
any other tags already on the recipe. If meal type is ambiguous (e.g. banana bread →
|
|
breakfast or dessert), ask the user — don't pick silently.
|
|
- Meal-type tag IDs in the family Mealie:
|
|
- `Breakfast` = `38fb71bf-a89d-45ee-9d21-0b1e5f716569`
|
|
- `Lunch` = `5f0ce972-2243-4ef6-b3c7-ad24f8561e84`
|
|
- `Dinner` = `bc992ba9-80ce-4407-8d1e-e69a7f659ebb`
|
|
- `Dessert` = `e7adc953-3f23-4032-804b-ada18b3a2da9`
|
|
- To append a tag without clobbering: GET the recipe, take `tags`, append the new
|
|
`{id, name, slug}` object, PATCH back with `{"name": <name>, "tags": [...]}`.
|
|
</conventions>
|
|
|
|
<auth-test>
|
|
GET /api/users/self — confirms the token works and returns group/household.
|
|
</auth-test>
|
|
|
|
<add-from-url>
|
|
Mealie scrapes recipe sites that publish schema.org Recipe JSON-LD.
|
|
POST /api/recipes/create/html-or-json with `{"url":"https://..."}` — returns the new slug.
|
|
Falls back to scraping HTML if no JSON-LD. PDFs and image URLs do NOT work here.
|
|
</add-from-url>
|
|
|
|
<add-from-raw>
|
|
Two-step: create then patch. Mealie has no single endpoint that takes a full recipe at once.
|
|
|
|
1. POST /api/recipes body `{"name": "..."}`
|
|
Returns the slug as a **raw JSON string** (e.g. `"my-recipe"`), not an object.
|
|
If a recipe with the same name exists, Mealie auto-suffixes (`-1`, `-2`). To avoid
|
|
duplicates, GET /api/recipes first and match by name before posting.
|
|
|
|
2. PATCH /api/recipes/{slug} with the full Recipe-Input body:
|
|
- `name`, `description`, `prepTime`, `cookTime`, `totalTime` (free strings)
|
|
- `recipeYield` (string), `recipeServings` (number), `recipeYieldQuantity` (number)
|
|
- `recipeIngredient: [...]` — see ingredient-parsing below
|
|
- `recipeInstructions: [{text, ingredientReferences: []}, ...]`
|
|
- `tags: [{id, name, slug}, ...]`, `recipeCategory: [{id, name, slug}, ...]`
|
|
- `notes: [{title, text}, ...]`
|
|
- `orgURL: "..."` — source link
|
|
- DO NOT include `slug` in the PATCH body; it triggers a rename attempt that conflicts
|
|
with the current slug and returns 400 "Recipe already exists".
|
|
</add-from-raw>
|
|
|
|
<edit-existing>
|
|
Same as step 2 above: PATCH /api/recipes/{slug} with the fields to change. Other fields
|
|
are preserved. To replace the entire ingredient list, send the full new `recipeIngredient`
|
|
array. To clear a list, send `[]`.
|
|
|
|
DELETE /api/recipes/{slug} removes the recipe.
|
|
</edit-existing>
|
|
|
|
<ingredient-parsing>
|
|
Mealie has an NLP parser (also `brute` and `openai` variants) that splits raw strings like
|
|
`"1½ tablespoons mayo"` into structured `{quantity, unit, food, note}` objects. The unit
|
|
and food are objects with their own `id` referencing rows in `/api/units` and `/api/foods`.
|
|
|
|
POST /api/parser/ingredients
|
|
body: `{"parser":"nlp", "ingredients":["1½ tablespoons mayo", "4 ears fresh corn, husked", ...]}`
|
|
returns one ParsedIngredient per input.
|
|
|
|
Parser gotchas that bite every time:
|
|
|
|
1. **Unit / food objects with `id: null` cannot be PATCHed onto a recipe** — server raises
|
|
`ValueError: Expected 'id' to be provided for unit/food`. The parser returns `id: null`
|
|
whenever the unit/food name isn't already in the database. Workflow: after parsing,
|
|
resolve each name against `/api/units` and `/api/foods` (auto-creating missing ones via
|
|
POST), then set `ing.unit.id` and `ing.food.id` before PATCHing the recipe.
|
|
|
|
2. **Seed the locale before parsing** or the parser fuzzy-matches against whatever you
|
|
created previously. Concrete bite: with only `tablespoon` in the unit table, every
|
|
"1 teaspoon" parses to `unit_id = <tablespoon's id>` because the Levenshtein distance
|
|
is 1. Pre-seed once per instance:
|
|
- POST /api/groups/seeders/units `{"locale":"en-US"}`
|
|
- POST /api/groups/seeders/foods `{"locale":"en-US"}`
|
|
These populate the standard US units (cup, teaspoon, tablespoon, ounce, etc.) and
|
|
common foods. After seeding the parser stops conflating teaspoon/tablespoon. Already
|
|
done on the family Mealie 2026-05-22.
|
|
|
|
3. **Sections in an ingredient list** (e.g. "Cookie dough" vs "Buttercream" in the same
|
|
recipe): set `title` on the FIRST ingredient of each section. Convention used in the
|
|
family import script: prefix the raw input with `[Section Name]`, the helper strips
|
|
the bracket and promotes it to `title` on the first ingredient of that group.
|
|
|
|
4. **Instructions also have a hidden required field**: each step object must include
|
|
`ingredientReferences: []`. The OpenAPI schema says it defaults to `[]`, but PATCH's
|
|
`model_dump(exclude_unset=True)` excludes it and the SQLAlchemy model then raises
|
|
`RecipeInstruction.__init__() missing 1 required positional argument: 'ingredient_references'`.
|
|
Always send `ingredientReferences: []` explicitly.
|
|
</ingredient-parsing>
|
|
|
|
<tags-categories>
|
|
RecipeTag and RecipeCategory objects in a PATCH body need **all three** of `id`, `name`,
|
|
`slug`. Sending just name+slug produces a misleading 400 "Recipe already exists" (the
|
|
real cause is a SQL integrity error logged server-side as `SQL Integrity Error on recipe
|
|
controller action`).
|
|
|
|
Create with POST /api/organizers/tags or POST /api/organizers/categories — body
|
|
`{"name":"..."}` — response contains the `id` and `slug` to reuse.
|
|
|
|
Existing IDs in the family Mealie:
|
|
- Tag `Mexican Street Eats Class` = `bb6841c5-3b6d-4569-b707-3d14bea83d7b`
|
|
- Category `Mexican` = `6ee64be6-83ce-4d6e-926c-62edfb791a41`
|
|
</tags-categories>
|
|
|
|
<misleading-errors>
|
|
- `400 "Recipe already exists"` on PATCH → almost always means tag/category/food/unit
|
|
is missing an `id`. Check the server logs (`kubectl --context sjc001 -n mealie logs
|
|
-l app=mealie`) for `SQL Integrity Error` to confirm.
|
|
- `500 TypeError` on PATCH → missing `ingredientReferences: []` on an instruction.
|
|
- `500 ValueError: Expected 'id' to be provided for unit` → parsed ingredient still has
|
|
`unit.id: null` or `food.id: null`; resolve via /api/units, /api/foods first.
|
|
- `500` on POST /api/foods or /api/units when the name already exists → list and dedupe
|
|
before creating, or catch the UniqueViolation.
|
|
</misleading-errors>
|
|
|
|
<reusable-import-script>
|
|
A working ingest script for the Mexican Street Eats PDF lives at
|
|
`/tmp/add_mealie_recipes.py` during the 2026-05-22 session. Pattern to reuse:
|
|
|
|
1. Build food + unit name→id caches once (GET /api/foods, /api/units, paginate).
|
|
2. For each raw ingredient line, POST to /api/parser/ingredients.
|
|
3. For each parsed result with `unit.id: null` or `food.id: null`, look up by lowercased
|
|
name in the cache; POST to create if missing; update cache.
|
|
4. For sectioned recipes, strip `[Section]` prefix and set `title` on first item.
|
|
5. Build PATCH body with `recipeIngredient`, `recipeInstructions` (each with
|
|
`ingredientReferences: []`), `tags` and `recipeCategory` (with ids).
|
|
6. POST to /api/recipes for the name, then PATCH /api/recipes/{slug} with the body.
|
|
</reusable-import-script>
|