"""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= 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