4c60b5b5fa
- New tool: get_recipe_categories() lists available recipe category IDs - Enhanced create_recipe and update_recipe with optional category_ids parameter - Extended get_recipe and get_recipes to include category_ids in response - Updated parse_recipe_full() to extract recipeCategoryIdList from API response - Extended build_create_params() and build_update_params() to handle category_ids Recipe categories are managed via recipe.recipeCategoryIdList in mprecipeput API. Categories are represented as a list of category IDs (e.g. ["category/23431854_2"]). No direct API endpoint exists for listing all categories; they are discovered from existing recipes or must be known in advance. Version: 0.7.5 → 0.8.0 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
230 lines
9.1 KiB
Python
230 lines
9.1 KiB
Python
"""Recipe helper functions for the Family Wall recipe box.
|
|
|
|
Verified endpoints (2026-04-16 via FW_DEBUG=1):
|
|
- Create: POST mprecipeput — params use 'recipe.' prefix (recipe.name, etc.)
|
|
- Update: POST mprecipeput with recipe.metaId — same prefix convention
|
|
- Read all: POST metasync with id='recipe' — response at a00.r.r.updatedCreated[]
|
|
- Delete: POST metadelete with id=<recipe_metaId>
|
|
|
|
Newline normalisation (bug fix v0.6.1):
|
|
LLM tool calls may pass literal backslash-n ('\\n', two chars) instead of a
|
|
real newline character. _normalize_newlines() converts them before sending
|
|
to the API so the server splits ingredients/instructions correctly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
|
|
def _normalize_newlines(text: str) -> str:
|
|
"""Replace literal backslash-n sequences with real newline characters.
|
|
|
|
When an LLM generates a tool call it may produce ``"line1\\nline2"``
|
|
(two chars: backslash + n) instead of ``"line1\\nline2"`` (real newline).
|
|
The Family Wall API splits ingredients and instructions on real newlines
|
|
only, so we normalise before sending.
|
|
|
|
Args:
|
|
text: Free-text string that may contain literal ``\\n`` sequences.
|
|
|
|
Returns:
|
|
The same string with every ``\\n`` replaced by a real ``\\n``.
|
|
"""
|
|
return text.replace("\\n", "\n")
|
|
|
|
|
|
def parse_recipe_summary(raw: dict[str, Any]) -> dict[str, Any]:
|
|
"""Extract a compact recipe summary from a raw API recipe object.
|
|
|
|
Args:
|
|
raw: Raw recipe dict from the API (metasync or mprecipeput response).
|
|
|
|
Returns:
|
|
Dict with keys: id, name, prep_time_minutes, cook_time_minutes, serves,
|
|
description, can_delete.
|
|
"""
|
|
prep = raw.get("prepTime")
|
|
cook = raw.get("cookTime")
|
|
srv = raw.get("serves")
|
|
return {
|
|
"id": raw.get("metaId"),
|
|
"name": raw.get("name"),
|
|
"description": raw.get("description") or None,
|
|
"prep_time_minutes": int(prep) if prep else None,
|
|
"cook_time_minutes": int(cook) if cook else None,
|
|
"serves": int(srv) if srv else None,
|
|
"can_delete": raw.get("rights", {}).get("canDelete") == "true",
|
|
}
|
|
|
|
|
|
def parse_recipe_full(raw: dict[str, Any]) -> dict[str, Any]:
|
|
"""Extract the full recipe from a raw API recipe object.
|
|
|
|
Args:
|
|
raw: Raw recipe dict from the API (metasync or mprecipeput response).
|
|
|
|
Returns:
|
|
Dict with all recipe fields including ingredients, instructions, etc.
|
|
"""
|
|
prep = raw.get("prepTime")
|
|
cook = raw.get("cookTime")
|
|
srv = raw.get("serves")
|
|
|
|
# ingredientsList is auto-parsed by the server; normalise to a plain list of names.
|
|
ingredients_list_raw: list[dict[str, Any]] = raw.get("ingredientsList") or []
|
|
ingredients_parsed = [item.get("name") for item in ingredients_list_raw if item.get("name")]
|
|
|
|
# Extract recipe categories from API response.
|
|
# The API provides both recipeCategoryIdList (actual IDs) and recipeCategories
|
|
# (system name strings). We use the ID list as the authoritative source.
|
|
categories: list[str] = []
|
|
category_ids: list[str] = raw.get("recipeCategoryIdList") or []
|
|
for cat_id in category_ids:
|
|
if cat_id:
|
|
categories.append(cat_id)
|
|
|
|
return {
|
|
"id": raw.get("metaId"),
|
|
"name": raw.get("name"),
|
|
"description": raw.get("description") or None,
|
|
"ingredients": raw.get("ingredients") or None,
|
|
"ingredients_parsed": ingredients_parsed,
|
|
"instructions": raw.get("instructions") or None,
|
|
"prep_time_minutes": int(prep) if prep else None,
|
|
"cook_time_minutes": int(cook) if cook else None,
|
|
"serves": int(srv) if srv else None,
|
|
"url": raw.get("url") or None,
|
|
"is_favorite": raw.get("isFavorite") == "true",
|
|
"can_delete": raw.get("rights", {}).get("canDelete") == "true",
|
|
"can_update": raw.get("rights", {}).get("canUpdate") == "true",
|
|
"created_at": raw.get("creationDate"),
|
|
"account_id": raw.get("accountId"),
|
|
"category_ids": categories,
|
|
}
|
|
|
|
|
|
def build_create_params(
|
|
name: str,
|
|
description: str | None = None,
|
|
ingredients: str | None = None,
|
|
instructions: str | None = None,
|
|
prep_time_minutes: int | None = None,
|
|
cook_time_minutes: int | None = None,
|
|
serves: int | None = None,
|
|
url: str | None = None,
|
|
category_ids: list[str] | None = None,
|
|
) -> dict[str, str | list[str]]:
|
|
"""Build the form parameters for a mprecipeput create call.
|
|
|
|
The Family Wall API requires the 'recipe.' prefix for all recipe fields.
|
|
isRecipe='true' is always required.
|
|
|
|
Newline normalisation is applied to ingredients and instructions so that
|
|
literal ``\\n`` sequences sent by an LLM are converted to real newlines,
|
|
which the server uses to split the ingredient list.
|
|
|
|
Args:
|
|
name: Recipe title (required).
|
|
description: Optional description.
|
|
ingredients: Optional free-text ingredients, lines separated by ``\\n``.
|
|
Literal backslash-n sequences are normalised to real newlines.
|
|
instructions: Optional free-text cooking instructions, lines separated
|
|
by ``\\n``. Literal backslash-n sequences are normalised.
|
|
prep_time_minutes: Optional preparation time in minutes.
|
|
cook_time_minutes: Optional cooking time in minutes.
|
|
serves: Optional number of servings.
|
|
url: Optional external URL (e.g. original recipe source).
|
|
category_ids: Optional list of recipe category IDs
|
|
(e.g. ``["category/23431854_2"]``).
|
|
|
|
Returns:
|
|
Dict of form parameters ready to send to mprecipeput.
|
|
"""
|
|
params: dict[str, str | list[str]] = {
|
|
"recipe.name": name,
|
|
"recipe.isRecipe": "true",
|
|
}
|
|
if description is not None:
|
|
params["recipe.description"] = description
|
|
if ingredients is not None:
|
|
params["recipe.ingredients"] = _normalize_newlines(ingredients)
|
|
if instructions is not None:
|
|
params["recipe.instructions"] = _normalize_newlines(instructions)
|
|
if prep_time_minutes is not None:
|
|
params["recipe.prepTime"] = str(prep_time_minutes)
|
|
if cook_time_minutes is not None:
|
|
params["recipe.cookTime"] = str(cook_time_minutes)
|
|
if serves is not None:
|
|
params["recipe.serves"] = str(serves)
|
|
if url is not None:
|
|
params["recipe.url"] = url
|
|
if category_ids is not None:
|
|
params["recipe.recipeCategoryIdList"] = category_ids
|
|
return params
|
|
|
|
|
|
def build_update_params(
|
|
recipe_id: str,
|
|
current_name: str,
|
|
name: str | None = None,
|
|
description: str | None = None,
|
|
ingredients: str | None = None,
|
|
instructions: str | None = None,
|
|
prep_time_minutes: int | None = None,
|
|
cook_time_minutes: int | None = None,
|
|
serves: int | None = None,
|
|
url: str | None = None,
|
|
category_ids: list[str] | None = None,
|
|
) -> dict[str, str | list[str]]:
|
|
"""Build the form parameters for a mprecipeput update call.
|
|
|
|
Identical to :func:`build_create_params` except that ``recipe.metaId`` is
|
|
included so the API updates the existing recipe instead of creating a new one.
|
|
``recipe.name`` and ``recipe.isRecipe`` are always present — the name falls
|
|
back to *current_name* when the caller does not provide a new one.
|
|
|
|
Newline normalisation is applied to ingredients and instructions (see
|
|
:func:`_normalize_newlines`).
|
|
|
|
Args:
|
|
recipe_id: metaId of the recipe to update (e.g. ``"recipe/123_456"``).
|
|
current_name: The recipe's existing name, used as fallback when *name*
|
|
is not provided. Fetched from the API before calling this function.
|
|
name: New recipe title (omit to keep existing).
|
|
description: New description (omit to keep existing).
|
|
ingredients: New ingredients text (omit to keep existing).
|
|
instructions: New instructions text (omit to keep existing).
|
|
prep_time_minutes: New preparation time in minutes (omit to keep existing).
|
|
cook_time_minutes: New cooking time in minutes (omit to keep existing).
|
|
serves: New number of servings (omit to keep existing).
|
|
url: New external URL (omit to keep existing).
|
|
category_ids: New list of recipe category IDs (omit to keep existing).
|
|
Pass empty list to remove all categories.
|
|
|
|
Returns:
|
|
Dict of form parameters ready to send to mprecipeput.
|
|
"""
|
|
params: dict[str, str | list[str]] = {
|
|
"recipe.metaId": recipe_id,
|
|
"recipe.name": name if name is not None else current_name,
|
|
"recipe.isRecipe": "true",
|
|
}
|
|
if description is not None:
|
|
params["recipe.description"] = description
|
|
if ingredients is not None:
|
|
params["recipe.ingredients"] = _normalize_newlines(ingredients)
|
|
if instructions is not None:
|
|
params["recipe.instructions"] = _normalize_newlines(instructions)
|
|
if prep_time_minutes is not None:
|
|
params["recipe.prepTime"] = str(prep_time_minutes)
|
|
if cook_time_minutes is not None:
|
|
params["recipe.cookTime"] = str(cook_time_minutes)
|
|
if serves is not None:
|
|
params["recipe.serves"] = str(serves)
|
|
if url is not None:
|
|
params["recipe.url"] = url
|
|
if category_ids is not None:
|
|
params["recipe.recipeCategoryIdList"] = category_ids
|
|
return params
|