diff --git a/README.md b/README.md index 1f73d5e..5fea523 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ cached session, so it also overrides a stale cached timezone. | `update_custom_food` | Edit a custom food in place: name, nutrition, serving size, or barcodes | | `delete_custom_food` | Retire a custom food so it leaves search and the Custom Foods list (existing diary entries are kept) | | `add_recipe` | Create a recipe from existing foods referenced by ID and gram weight | +| `update_recipe` | Edit a recipe in place: name, ingredients and amounts, serving size, or notes; nutrition is recalculated | | `import_recipe` | Create a recipe from a free-text ingredient list; Cronometer matches each line to a database food and converts the amount to grams | | `copy_day` | Copy all entries from the previous day | | `mark_day_complete` | Mark a diary day as complete or incomplete | diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index 89e4575..706fe01 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -1049,69 +1049,7 @@ def create_recipe( """ if not ingredients: raise ValueError("create_recipe requires at least one ingredient") - - # Normalize to (food_id, grams, measure_id|None) and validate up front so - # a malformed entry fails before any network call. - parsed: list[tuple[int, float, int | None]] = [] - for item in ingredients: - if len(item) == 2: - food_id, grams = item - measure_id = None - elif len(item) == 3: - food_id, grams, measure_id = item - else: - raise ValueError( - f"Each ingredient must be (food_id, grams) or " - f"(food_id, grams, measure_id); got {item!r}" - ) - if grams <= 0: - raise ValueError( - f"Ingredient grams must be positive; food {food_id} got {grams!r}" - ) - parsed.append((int(food_id), float(grams), measure_id)) - - total_grams = sum(g for _, g, _ in parsed) - - # One batch call resolves every ingredient's measures, translation, and - # per-100g nutrient profile. - foods = self.get_foods([fid for fid, _, _ in parsed]) - food_by_id = {f.get("id"): f for f in foods if isinstance(f, dict)} - missing = [fid for fid, _, _ in parsed if fid not in food_by_id] - if missing: - raise CronometerError(f"Ingredient food IDs not found: {missing}") - - ingredient_rows: list[dict] = [] - batch_totals: dict[int, float] = {} - for food_id, grams, measure_id in parsed: - food = food_by_id[food_id] - if measure_id is None: - measure_id = _gram_measure_id(food) - ingredient_rows.append( - { - "id": 0, - "foodId": food_id, - "measureId": measure_id, - "translationId": _translation_id(food), - "grams": grams, - "value": grams, - } - ) - # Ingredient nutrients are per-100g; accumulate the batch total. - for n in food.get("nutrients", []): - if not isinstance(n, dict): - continue - nid = n.get("id") - amount = n.get("amount") - if nid is None or not isinstance(amount, (int, float)): - continue - batch_totals[nid] = batch_totals.get(nid, 0.0) + amount * grams / 100.0 - - # Cronometer stores recipe nutrients per-100g of the finished batch. - scale = 100.0 / total_grams - nutrients = [ - {"id": nid, "amount": round(amount * scale, 6)} - for nid, amount in sorted(batch_totals.items()) - ] + ingredient_rows, nutrients, total_grams = self._recipe_ingredients(ingredients) # The first measure in the array becomes the server-assigned # defaultMeasureId, so the serving measure leads. @@ -1179,6 +1117,185 @@ def create_recipe( "ingredient_count": len(ingredient_rows), } + def update_recipe( + self, + food_id: int, + *, + name: str | None = None, + ingredients: list[tuple] | None = None, + serving_grams: float | None = None, + comments: str | None = None, + ) -> dict: + """Edit a recipe created with create_recipe (or in the app) in place. + + Like the app, this re-sends the whole recipe to /api/v2/add_food with + its id. New ingredients replace the old list, and the nutrient + profile is recalculated the way create_recipe does it (the server + stores what it is sent; it doesn't recalculate). The "full recipe" + measure follows the new batch weight. The default serving keeps its + weight unless serving_grams is given, except when it was the whole + batch, in which case it stays the whole batch. Existing measures keep + their ids, so diary entries that logged the recipe stay valid (their + nutrition changes with the recipe, as in the app). + + Saved meals are serving-based and are refused. + + Args: + food_id: The recipe's food ID. + name: New name. + ingredients: New ingredient list, in create_recipe's format. + serving_grams: New weight of the default serving. + comments: New recipe notes. + + Returns {"food_id", "name", "total_grams", "ingredient_count"}. + """ + if ( + name is None + and ingredients is None + and serving_grams is None + and comments is None + ): + raise ValueError( + "update_recipe needs name, ingredients, serving_grams or comments" + ) + if name is not None and not name.strip(): + raise ValueError("name must not be empty") + if serving_grams is not None and serving_grams <= 0: + raise ValueError(f"serving_grams must be positive, got {serving_grams}") + if ingredients is not None and not ingredients: + raise ValueError("ingredients must not be empty") + + with self._food_lock(food_id): + food = self.get_food(food_id) + if food.get("source") != "Custom" or not food.get("ingredients"): + raise CronometerError( + f"Food {food_id} ({food.get('name')!r}) is not a recipe you created" + ) + if food.get("meal"): + raise CronometerError( + f"Food {food_id} ({food.get('name')!r}) is a saved meal; only " + f"recipes can be edited" + ) + + measures = food.get("measures") or [] + full = next((m for m in measures if m.get("name") == "full recipe"), None) + old_total = full.get("value") if full else None + default = next( + (m for m in measures if m.get("id") == food.get("defaultMeasureId")), + None, + ) + + total_grams = sum(i.get("grams") or 0 for i in food["ingredients"]) + if ingredients is not None: + rows, nutrients, total_grams = self._recipe_ingredients(ingredients) + food["ingredients"] = rows + food["nutrients"] = nutrients + if full is not None: + full["value"] = total_grams + if ( + default is not None + and default is not full + and serving_grams is None + and old_total is not None + and default.get("value") == old_total + ): + default["value"] = total_grams + if serving_grams is not None: + if default is None: + raise CronometerError( + f"Recipe {food_id} has no default serving to resize" + ) + default["value"] = serving_grams + if name is not None: + food["name"] = name + for t in food.get("translations") or []: + t["name"] = name + if comments is not None: + food["comments"] = comments + + self._save_custom_food(food) + logger.info("Updated recipe %r (id=%d)", food["name"], food_id) + return { + "food_id": food_id, + "name": food["name"], + "total_grams": total_grams, + "ingredient_count": len(food["ingredients"]), + } + + def _recipe_ingredients( + self, ingredients: list[tuple] + ) -> tuple[list[dict], list[dict], float]: + """Resolve recipe ingredients and the recipe's nutrient profile. + + Shared by create_recipe and update_recipe; see create_recipe for the + ingredient tuple format. Returns (ingredient rows, nutrients per 100 g + of the batch, total batch grams). + """ + # Normalize to (food_id, grams, measure_id|None) and validate up front so + # a malformed entry fails before any network call. + parsed: list[tuple[int, float, int | None]] = [] + for item in ingredients: + if len(item) == 2: + food_id, grams = item + measure_id = None + elif len(item) == 3: + food_id, grams, measure_id = item + else: + raise ValueError( + f"Each ingredient must be (food_id, grams) or " + f"(food_id, grams, measure_id); got {item!r}" + ) + if grams <= 0: + raise ValueError( + f"Ingredient grams must be positive; food {food_id} got {grams!r}" + ) + parsed.append((int(food_id), float(grams), measure_id)) + + total_grams = sum(g for _, g, _ in parsed) + + # One batch call resolves every ingredient's measures, translation, and + # per-100g nutrient profile. + foods = self.get_foods([fid for fid, _, _ in parsed]) + food_by_id = {f.get("id"): f for f in foods if isinstance(f, dict)} + missing = [fid for fid, _, _ in parsed if fid not in food_by_id] + if missing: + raise CronometerError(f"Ingredient food IDs not found: {missing}") + + ingredient_rows: list[dict] = [] + batch_totals: dict[int, float] = {} + for food_id, grams, measure_id in parsed: + food = food_by_id[food_id] + if measure_id is None: + measure_id = _gram_measure_id(food) + ingredient_rows.append( + { + "id": 0, + "foodId": food_id, + "measureId": measure_id, + "translationId": _translation_id(food), + "grams": grams, + "value": grams, + } + ) + # Ingredient nutrients are per-100g; accumulate the batch total. + for n in food.get("nutrients", []): + if not isinstance(n, dict): + continue + nid = n.get("id") + amount = n.get("amount") + if nid is None or not isinstance(amount, (int, float)): + continue + batch_totals[nid] = batch_totals.get(nid, 0.0) + amount * grams / 100.0 + + # Cronometer stores recipe nutrients per-100g of the finished batch. + scale = 100.0 / total_grams + nutrients = [ + {"id": nid, "amount": round(amount * scale, 6)} + for nid, amount in sorted(batch_totals.items()) + ] + + return ingredient_rows, nutrients, total_grams + # ------------------------------------------------------------------ # Recipe import (free-text ingredients) # ------------------------------------------------------------------ diff --git a/src/cronometer_api_mcp/server.py b/src/cronometer_api_mcp/server.py index af0b74f..51b2564 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -557,16 +557,25 @@ def get_food_details(food_id: int) -> str: } ) - return _ok( - { - "food_id": data.get("id"), - "name": data.get("name"), - "default_measure_id": data.get("defaultMeasureId"), - "measures": measures, - "barcodes": data.get("barcodes", []), - "nutrients": data.get("nutrients", []), - } - ) + result = { + "food_id": data.get("id"), + "name": data.get("name"), + "default_measure_id": data.get("defaultMeasureId"), + "measures": measures, + "barcodes": data.get("barcodes", []), + "nutrients": data.get("nutrients", []), + } + if data.get("ingredients"): + # Recipes and meals: in the shape add_recipe/update_recipe take. + result["ingredients"] = [ + { + "food_id": i.get("foodId"), + "grams": i.get("grams"), + "measure_id": i.get("measureId"), + } + for i in data["ingredients"] + ] + return _ok(result) except Exception as e: return _err(e) @@ -800,19 +809,7 @@ def add_recipe( comments: Free-text recipe notes. """ try: - parsed = [] - for item in ingredients: - if "food_id" not in item or "grams" not in item: - return _err( - ValueError( - f"Each ingredient needs 'food_id' and 'grams'; got {item!r}" - ) - ) - if item.get("measure_id") is not None: - parsed.append((item["food_id"], item["grams"], item["measure_id"])) - else: - parsed.append((item["food_id"], item["grams"])) - + parsed = _parse_ingredients(ingredients) client = _get_client() result = client.create_recipe( name, @@ -838,6 +835,62 @@ def add_recipe( return _err(e) +@mcp.tool(annotations=_DESTRUCTIVE) +def update_recipe( + food_id: int, + name: str | None = None, + ingredients: list[dict] | None = None, + serving_grams: float | None = None, + comments: str | None = None, +) -> str: + """Edit a recipe you created: rename it, change its ingredients or + amounts, resize its default serving, or change its notes. + + Pass only what should change. A new ingredient list replaces the whole + old one (use get_food_details to read the current ingredients first), and + the nutrition is recalculated from it. Diary entries that already logged + the recipe keep pointing at it and follow the new nutrition, as in the + app. Saved meals cannot be edited with this tool. + + Args: + food_id: The recipe's food ID. + name: New recipe name. + ingredients: New list of {"food_id": int, "grams": float} objects, + with an optional "measure_id", as for add_recipe. + serving_grams: New weight of the default serving in grams. If omitted + and the serving was the whole batch, it follows the new batch. + comments: New free-text recipe notes. + """ + try: + parsed = _parse_ingredients(ingredients) if ingredients is not None else None + client = _get_client() + result = client.update_recipe( + food_id, + name=name, + ingredients=parsed, + serving_grams=serving_grams, + comments=comments, + ) + return _ok(result) + except Exception as e: + return _err(e) + + +def _parse_ingredients(ingredients: list[dict]) -> list[tuple]: + """Turn the tools' ingredient dicts into create_recipe tuples.""" + parsed = [] + for item in ingredients: + if "food_id" not in item or "grams" not in item: + raise ValueError( + f"Each ingredient needs 'food_id' and 'grams'; got {item!r}" + ) + if item.get("measure_id") is not None: + parsed.append((item["food_id"], item["grams"], item["measure_id"])) + else: + parsed.append((item["food_id"], item["grams"])) + return parsed + + @mcp.tool(annotations=_WRITE) def import_recipe(ingredients: str, name: str | None = None) -> str: """Create a recipe from a free-text ingredient list. diff --git a/tests/test_custom_food_edit.py b/tests/test_custom_food_edit.py index 224a4fa..b2d5ce4 100644 --- a/tests/test_custom_food_edit.py +++ b/tests/test_custom_food_edit.py @@ -575,3 +575,128 @@ def stalling_post(endpoint, json=None): ] assert state["payloads"][1]["data"]["name"] == "A" assert state["payloads"][3]["data"]["retired"] is True + + +# --------------------------------------------------------------------------- +# update_recipe +# --------------------------------------------------------------------------- + +APPLE = { + "id": 5442, + "measures": [{"id": 14990, "name": "g", "value": 1, "amount": 1}], + "translations": [{"translationId": 8463}], + "nutrients": [{"id": 208, "amount": 52}, {"id": 203, "amount": 0.3}], +} +BANANA = { + "id": 5433, + "measures": [{"id": 14965, "name": "g", "value": 1, "amount": 1}], + "translations": [{"translationId": 8445}], + "nutrients": [{"id": 208, "amount": 89}, {"id": 203, "amount": 1.1}], +} + + +def stored_recipe(**overrides) -> dict: + """A recipe as get_food returns it after create_recipe: 100 g apple + + 100 g banana, default serving = the whole 200 g batch.""" + food = recipe() + food.update( + defaultMeasureId=1, + measures=[ + {"id": 1, "name": "Serving", "value": 200, "amount": 1, "type": "Weight"}, + {"id": 2, "name": "full recipe", "value": 200, "amount": 1}, + {"id": 3, "name": "g", "value": 1, "amount": 1, "type": "Weight"}, + ], + ingredients=[ + {"id": 11, "foodId": 5442, "measureId": 14990, "grams": 100}, + {"id": 12, "foodId": 5433, "measureId": 14965, "grams": 100}, + ], + translations=[ + {"translationId": 1, "name": "Test Recipe", "languageCode": "en"} + ], + ) + food.update(overrides) + return food + + +def test_update_recipe_recalculates_nutrients_and_batch_measures(tmp_path): + client, state = make_cold_client( + tmp_path, [stored_recipe(), {"foods": [APPLE, BANANA]}, OK] + ) + + result = client.update_recipe(FOOD_ID, ingredients=[(5442, 150), (5433, 100)]) + + data = state["payloads"][2]["data"] + assert data["id"] == FOOD_ID + assert [(i["foodId"], i["grams"]) for i in data["ingredients"]] == [ + (5442, 150.0), + (5433, 100.0), + ] + energy = {n["id"]: n["amount"] for n in data["nutrients"]}[208] + assert energy == pytest.approx((52 * 1.5 + 89) / 250 * 100) + values = {m["name"]: (m["id"], m["value"]) for m in data["measures"]} + assert values["full recipe"] == (2, 250) + assert values["Serving"] == (1, 250) # was the whole batch, stays so + assert values["g"] == (3, 1) # never dropped: the server refuses that + assert result == { + "food_id": FOOD_ID, + "name": "Test Recipe", + "total_grams": 250, + "ingredient_count": 2, + } + + +def test_update_recipe_keeps_a_partial_serving(tmp_path): + food = stored_recipe() + food["measures"][0]["value"] = 50 # a quarter of the batch + client, state = make_cold_client(tmp_path, [food, {"foods": [APPLE, BANANA]}, OK]) + + client.update_recipe(FOOD_ID, ingredients=[(5442, 150), (5433, 100)]) + + serving = state["payloads"][2]["data"]["measures"][0] + assert serving["value"] == 50 + + +def test_update_recipe_rename_only_touches_name_and_translation(tmp_path): + client, state = make_cold_client(tmp_path, [stored_recipe(), OK]) + + client.update_recipe(FOOD_ID, name="Renamed", serving_grams=80) + + data = state["payloads"][1]["data"] + assert data["name"] == "Renamed" + assert data["translations"][0]["name"] == "Renamed" + assert data["ingredients"] == stored_recipe()["ingredients"] + assert data["measures"][0]["value"] == 80 + assert "messages" not in data + + +@pytest.mark.parametrize( + ("food", "message"), + [ + (custom_food(), "not a recipe"), + (stored_recipe(meal=True), "saved meal"), + ], + ids=["plain food", "meal"], +) +def test_update_recipe_refuses_non_recipes(tmp_path, food, message): + client, state = make_cold_client(tmp_path, [food, OK]) + + with pytest.raises(CronometerError, match=message): + client.update_recipe(FOOD_ID, name="x") + assert len(state["payloads"]) == 1 + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({}, "needs name"), + ({"name": " "}, "empty"), + ({"serving_grams": 0}, "positive"), + ({"ingredients": []}, "empty"), + ], +) +def test_update_recipe_validates_before_any_request(tmp_path, kwargs, message): + client, state = make_cold_client(tmp_path, [stored_recipe(), OK]) + + with pytest.raises(ValueError, match=message): + client.update_recipe(FOOD_ID, **kwargs) + assert state["payloads"] == [] diff --git a/tests/test_server_import.py b/tests/test_server_import.py index 917f190..bc5d006 100644 --- a/tests/test_server_import.py +++ b/tests/test_server_import.py @@ -31,6 +31,7 @@ "remove_food_entry", "search_foods", "update_custom_food", + "update_recipe", }