Compare commits
4 Commits
e46c327875
...
7d693ef429
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d693ef429 | |||
| 42d78f4060 | |||
| 71c7a3414e | |||
| 67dac16059 |
+277
-16
@@ -53,6 +53,17 @@ READ_ONLY_TOOL_NAMES = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
IMPORT_TOOL_NAMES = READ_ONLY_TOOL_NAMES | frozenset(
|
||||||
|
{
|
||||||
|
"import_recipe_url",
|
||||||
|
"import_recipe_text",
|
||||||
|
"import_recipe_image",
|
||||||
|
"patch_recipe",
|
||||||
|
"parse_ingredients",
|
||||||
|
"set_cover_image",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MealieAuthError(RuntimeError):
|
class MealieAuthError(RuntimeError):
|
||||||
"""The Mealie token is missing, expired, or wrong (HTTP 401)."""
|
"""The Mealie token is missing, expired, or wrong (HTTP 401)."""
|
||||||
@@ -296,6 +307,45 @@ def import_recipe_url(url: str, check_duplicates: bool = True) -> dict[str, Any]
|
|||||||
return {"imported": True, "recipe": recipe, "report": _import_report(recipe)}
|
return {"imported": True, "recipe": recipe, "report": _import_report(recipe)}
|
||||||
|
|
||||||
|
|
||||||
|
_SECTION_HEADING_RE = re.compile(
|
||||||
|
r"(?im)^[ \t]*(Titel|Portioner|Ingredienser|Gör så här)[ \t]*(?:\([^)]*\))?[ \t]*:[ \t]*(.*)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
_STEP_NUMBER_RE = re.compile(r"^\s*\d+[.)]\s*")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_recipe_text(text: str) -> dict[str, str]:
|
||||||
|
"""Split the documented Swedish text block into its four sections.
|
||||||
|
|
||||||
|
A heading may carry a parenthetical marking — ``Gör så här (REKONSTRUERAD):``
|
||||||
|
— which belongs to the caller's note about the source, not to the recipe. It
|
||||||
|
is accepted on the heading line and dropped from the body.
|
||||||
|
"""
|
||||||
|
matches = list(_SECTION_HEADING_RE.finditer(text))
|
||||||
|
sections: dict[str, str] = {}
|
||||||
|
for index, match in enumerate(matches):
|
||||||
|
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||||||
|
body = f"{match.group(2)}\n{text[match.end():end]}"
|
||||||
|
sections[match.group(1).lower()] = body.strip()
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def _text_lines(body: str) -> list[str]:
|
||||||
|
return [line.strip() for line in body.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _servings_number(portions: str) -> int | None:
|
||||||
|
"""Read a serving count out of a Swedish yield line.
|
||||||
|
|
||||||
|
``recipeYield`` is free text, but Mealie scales ingredients and nutrition by
|
||||||
|
the numeric ``recipeServings`` — left at 0, per-serving figures have nothing
|
||||||
|
to divide by. A range takes its lower bound, which is how the rest of this
|
||||||
|
library stores "10-12 personer" (recipeServings 10).
|
||||||
|
"""
|
||||||
|
match = re.search(r"\d+", portions)
|
||||||
|
return int(match.group()) if match else None
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def import_recipe_text(text: str, source_url: str | None = None) -> dict[str, Any]:
|
def import_recipe_text(text: str, source_url: str | None = None) -> dict[str, Any]:
|
||||||
"""Import a normalized recipe text block (OCR, transcript, or hand-built Swedish).
|
"""Import a normalized recipe text block (OCR, transcript, or hand-built Swedish).
|
||||||
@@ -303,23 +353,63 @@ def import_recipe_text(text: str, source_url: str | None = None) -> dict[str, An
|
|||||||
Use this when the source is not a clean single-recipe page — an editorial
|
Use this when the source is not a clean single-recipe page — an editorial
|
||||||
roundup, a YouTube description, or a social caption. Build the text in Swedish
|
roundup, a YouTube description, or a social caption. Build the text in Swedish
|
||||||
with ``Titel`` / ``Portioner`` / ``Ingredienser`` / ``Gör så här`` sections.
|
with ``Titel`` / ``Portioner`` / ``Ingredienser`` / ``Gör så här`` sections.
|
||||||
|
|
||||||
|
The block is written with ``POST /api/recipes`` plus a ``PATCH``, not through
|
||||||
|
``/api/recipes/create/html-or-json``. That endpoint is a scraper entry point:
|
||||||
|
it runs recipe_scrapers over whatever ``data`` holds and answers 400 for
|
||||||
|
anything without schema.org markup, so plain text never survived it.
|
||||||
|
|
||||||
|
Ingredients are stored as readable ``note`` lines. Run ``parse_ingredients``
|
||||||
|
afterwards to add structure without changing how they read.
|
||||||
"""
|
"""
|
||||||
|
sections = _split_recipe_text(text)
|
||||||
required = ("Titel", "Ingredienser", "Gör så här")
|
required = ("Titel", "Ingredienser", "Gör så här")
|
||||||
missing = [
|
missing = [heading for heading in required if heading.lower() not in sections]
|
||||||
heading
|
|
||||||
for heading in required
|
|
||||||
if not re.search(rf"(?im)^\s*{re.escape(heading)}\s*:", text)
|
|
||||||
]
|
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Text import requires explicit Swedish section headings; missing: "
|
"Text import requires explicit Swedish section headings; missing: "
|
||||||
+ ", ".join(missing)
|
+ ", ".join(missing)
|
||||||
)
|
)
|
||||||
payload: dict[str, Any] = {"data": text}
|
|
||||||
|
title = sections["titel"]
|
||||||
|
ingredients = _text_lines(sections["ingredienser"])
|
||||||
|
steps = [_STEP_NUMBER_RE.sub("", line) for line in _text_lines(sections["gör så här"])]
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Text import requires a non-empty Titel")
|
||||||
|
if not ingredients:
|
||||||
|
raise ValueError("Text import requires at least one line under Ingredienser")
|
||||||
|
|
||||||
|
created = _request("POST", "/api/recipes", json={"name": title})
|
||||||
|
slug = created if isinstance(created, str) else (created or {}).get("slug")
|
||||||
|
if not slug:
|
||||||
|
raise RuntimeError(f"Mealie did not return a slug for the created recipe: {created!r}")
|
||||||
|
|
||||||
|
patch: dict[str, Any] = {
|
||||||
|
"recipeIngredient": [
|
||||||
|
{"quantity": 0, "unit": None, "food": None, "note": line} for line in ingredients
|
||||||
|
],
|
||||||
|
"recipeInstructions": [{"text": step} for step in steps],
|
||||||
|
}
|
||||||
|
if sections.get("portioner"):
|
||||||
|
patch["recipeYield"] = sections["portioner"]
|
||||||
|
servings = _servings_number(sections["portioner"])
|
||||||
|
if servings:
|
||||||
|
patch["recipeServings"] = servings
|
||||||
if source_url:
|
if source_url:
|
||||||
payload["url"] = source_url
|
patch["orgURL"] = source_url
|
||||||
created = _request("POST", "/api/recipes/create/html-or-json", json=payload)
|
|
||||||
recipe = get_recipe(created) if isinstance(created, str) else created
|
try:
|
||||||
|
_request("PATCH", f"/api/recipes/{slug}", json=patch)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
# The name-only recipe already exists at this point; say so instead of
|
||||||
|
# leaving an empty stub nobody knows about.
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Recipe {slug!r} was created but the content patch failed "
|
||||||
|
f"({exc.response.status_code}); it is empty in Mealie and needs "
|
||||||
|
"filling in or removing by hand"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
recipe = get_recipe(slug)
|
||||||
return {"imported": True, "recipe": recipe, "report": _import_report(recipe)}
|
return {"imported": True, "recipe": recipe, "report": _import_report(recipe)}
|
||||||
|
|
||||||
|
|
||||||
@@ -340,17 +430,169 @@ def import_recipe_image(image_path: str) -> dict[str, Any]:
|
|||||||
# --- Write ------------------------------------------------------------------
|
# --- Write ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
#: Recipe fields holding organizer objects, and the endpoint that owns each one.
|
||||||
|
_ORGANIZER_FIELDS = {
|
||||||
|
"tags": "/api/organizers/tags",
|
||||||
|
"recipeCategory": "/api/organizers/categories",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _organizers_by_name(path: str) -> dict[str, dict[str, Any]]:
|
||||||
|
data = _request("GET", path, params={"perPage": 200})
|
||||||
|
items = data.get("items", data if isinstance(data, list) else [])
|
||||||
|
return {(item.get("name") or "").casefold(): item for item in items}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_organizers(field: str, entries: Any) -> Any:
|
||||||
|
"""Turn tag/category shorthand into the objects Mealie's schema demands.
|
||||||
|
|
||||||
|
``RecipeTag`` and ``RecipeCategory`` require both ``name`` and ``slug``, so
|
||||||
|
patching ``[{"name": "Källa: TikTok"}]`` answers 422 (verified against the
|
||||||
|
instance's own OpenAPI schema, v3.22.0). An existing organizer is reused so
|
||||||
|
tagging cannot fork the vocabulary, and a genuinely new name is created
|
||||||
|
through its own endpoint — which is what supplies the slug Mealie itself
|
||||||
|
would have picked.
|
||||||
|
"""
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return entries
|
||||||
|
path = _ORGANIZER_FIELDS[field]
|
||||||
|
existing: dict[str, dict[str, Any]] | None = None
|
||||||
|
resolved: list[Any] = []
|
||||||
|
for entry in entries:
|
||||||
|
if isinstance(entry, dict) and entry.get("slug"):
|
||||||
|
resolved.append(entry)
|
||||||
|
continue
|
||||||
|
name = entry if isinstance(entry, str) else (entry or {}).get("name")
|
||||||
|
if not name:
|
||||||
|
raise ValueError(f"{field} entries need a name; got {entry!r}")
|
||||||
|
if existing is None:
|
||||||
|
existing = _organizers_by_name(path)
|
||||||
|
match = existing.get(name.casefold())
|
||||||
|
if match is None:
|
||||||
|
match = _request("POST", path, json={"name": name})
|
||||||
|
existing[name.casefold()] = match
|
||||||
|
resolved.append(match)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
#: Mealie's own nutrition field names. Anything else is silently dropped by the
|
||||||
|
#: API, so a misspelt key has to be caught here or the number never lands.
|
||||||
|
_NUTRITION_FIELDS = frozenset({
|
||||||
|
"calories",
|
||||||
|
"carbohydrateContent",
|
||||||
|
"cholesterolContent",
|
||||||
|
"fatContent",
|
||||||
|
"fiberContent",
|
||||||
|
"proteinContent",
|
||||||
|
"saturatedFatContent",
|
||||||
|
"sodiumContent",
|
||||||
|
"sugarContent",
|
||||||
|
"transFatContent",
|
||||||
|
"unsaturatedFatContent",
|
||||||
|
})
|
||||||
|
|
||||||
|
#: Atwater factors: kcal per gram of each macronutrient.
|
||||||
|
_ENERGY_PER_GRAM = {"proteinContent": 4.0, "fatContent": 9.0, "carbohydrateContent": 4.0}
|
||||||
|
|
||||||
|
_NUMBER_RE = re.compile(r"^\s*(\d+(?:[.,]\d+)?)\s*(?:kcal|cal|kj|mg|g|gram|grams)?\s*$", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def _nutrition_number(field: str, value: Any) -> float:
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
match = _NUMBER_RE.match(str(value))
|
||||||
|
if not match:
|
||||||
|
raise ValueError(
|
||||||
|
f"nutrition.{field} must be a plain number (Mealie renders the unit "
|
||||||
|
f"itself); got {value!r}"
|
||||||
|
)
|
||||||
|
return float(match.group(1).replace(",", "."))
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_nutrition(nutrition: Any, servings: Any) -> dict[str, str]:
|
||||||
|
"""Check a nutrition block before it reaches Mealie, and normalize it.
|
||||||
|
|
||||||
|
Mealie stores these as free-text strings and validates nothing: a misspelt
|
||||||
|
key, a unit suffix, or a whole-recipe total in a per-serving field is all
|
||||||
|
accepted and then displayed as fact. Three things are checked instead:
|
||||||
|
|
||||||
|
* the keys are Mealie's own, so a number cannot vanish into `carbs`;
|
||||||
|
* every value is a bare number, matching how the library already stores them;
|
||||||
|
* energy agrees with the macros (4/9/4 kcal per gram) within 25%, which is
|
||||||
|
what catches an arithmetic slip in an estimate.
|
||||||
|
|
||||||
|
``recipeServings`` must be known, because Mealie's figures are per serving and
|
||||||
|
a recipe written "4-6 personer" carries no number Mealie can use.
|
||||||
|
"""
|
||||||
|
if not isinstance(nutrition, dict):
|
||||||
|
raise ValueError(f"nutrition must be an object; got {nutrition!r}")
|
||||||
|
|
||||||
|
unknown = sorted(set(nutrition) - _NUTRITION_FIELDS)
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"unknown nutrition fields {unknown}; Mealie's names are "
|
||||||
|
f"{sorted(_NUTRITION_FIELDS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
portions = float(servings or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
portions = 0.0
|
||||||
|
if portions <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
"nutrition is stored per serving, but the recipe has no recipeServings; "
|
||||||
|
"set it in the same patch (a yield string like '4-6 personer' is not a "
|
||||||
|
"number Mealie can divide by)"
|
||||||
|
)
|
||||||
|
|
||||||
|
values = {
|
||||||
|
field: _nutrition_number(field, value)
|
||||||
|
for field, value in nutrition.items()
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
macros = {field: values[field] for field in _ENERGY_PER_GRAM if field in values}
|
||||||
|
if "calories" in values and len(macros) == len(_ENERGY_PER_GRAM):
|
||||||
|
expected = sum(grams * _ENERGY_PER_GRAM[field] for field, grams in macros.items())
|
||||||
|
stated = values["calories"]
|
||||||
|
if expected and abs(stated - expected) / expected > 0.25:
|
||||||
|
raise ValueError(
|
||||||
|
f"calories {stated:.0f} does not match the macros "
|
||||||
|
f"({macros['proteinContent']:.0f} g protein, {macros['fatContent']:.0f} g "
|
||||||
|
f"fat, {macros['carbohydrateContent']:.0f} g carbohydrate imply "
|
||||||
|
f"{expected:.0f} kcal). Recheck the estimate; both must be per serving."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {field: f"{value:.0f}" for field, value in values.items()}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def patch_recipe(slug_or_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
def patch_recipe(slug_or_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Apply an explicit Mealie recipe patch, then read the recipe back.
|
"""Apply an explicit Mealie recipe patch, then read the recipe back.
|
||||||
|
|
||||||
Use this for tags and categories too — the bulk-action endpoints return 500 on
|
Use this for tags and categories too — the bulk-action endpoints return 500 on
|
||||||
this instance. Patch ``recipeCategory`` and ``tags`` directly. The stable
|
this instance. Patch ``recipeCategory`` and ``tags`` directly; a bare name or
|
||||||
recipe id is captured before the PATCH because changing ``name`` also changes
|
``{"name": ...}`` is accepted and resolved to the existing organizer, or
|
||||||
Mealie's slug, making the old slug invalid for read-back.
|
created if the library has no such name.
|
||||||
|
|
||||||
|
``nutrition`` is checked before it is sent: Mealie's own field names, bare
|
||||||
|
numbers, and energy that agrees with the macros. The figures are per serving,
|
||||||
|
so ``recipeServings`` has to be set — patch it here if the import only left a
|
||||||
|
yield string.
|
||||||
|
|
||||||
|
The stable recipe id is captured before the PATCH because changing ``name``
|
||||||
|
also changes Mealie's slug, making the old slug invalid for read-back.
|
||||||
"""
|
"""
|
||||||
before = get_recipe(slug_or_id)
|
before = get_recipe(slug_or_id)
|
||||||
stable_id = before.get("id") or slug_or_id
|
stable_id = before.get("id") or slug_or_id
|
||||||
|
patch = {
|
||||||
|
field: _resolve_organizers(field, value) if field in _ORGANIZER_FIELDS else value
|
||||||
|
for field, value in patch.items()
|
||||||
|
}
|
||||||
|
if "nutrition" in patch:
|
||||||
|
patch["nutrition"] = _validated_nutrition(
|
||||||
|
patch["nutrition"], patch.get("recipeServings", before.get("recipeServings"))
|
||||||
|
)
|
||||||
_request("PATCH", f"/api/recipes/{slug_or_id}", json=patch)
|
_request("PATCH", f"/api/recipes/{slug_or_id}", json=patch)
|
||||||
return get_recipe(stable_id)
|
return get_recipe(stable_id)
|
||||||
|
|
||||||
@@ -609,10 +851,19 @@ def set_cover_image(
|
|||||||
|
|
||||||
Local uploads must send the multipart ``extension`` field or the upload fails
|
Local uploads must send the multipart ``extension`` field or the upload fails
|
||||||
validation silently on this instance.
|
validation silently on this instance.
|
||||||
|
|
||||||
|
The recipe is resolved to its stored slug first: ``/api/recipes/{slug}`` takes
|
||||||
|
a slug *or* an id, but the sub-routes under it are slug-only. Verified on
|
||||||
|
v3.22.0 — an id answers 404 on ``POST .../image``, 500 on ``PUT .../image``
|
||||||
|
and 500 on ``GET .../comments``, none of which say what is actually wrong.
|
||||||
"""
|
"""
|
||||||
if not source_url and not image_path:
|
if not source_url and not image_path:
|
||||||
raise ValueError("Provide either source_url or image_path")
|
raise ValueError("Provide either source_url or image_path")
|
||||||
|
|
||||||
|
slug = (get_recipe(slug_or_id) or {}).get("slug")
|
||||||
|
if not slug:
|
||||||
|
raise RuntimeError(f"No recipe found for {slug_or_id!r}")
|
||||||
|
|
||||||
if image_path:
|
if image_path:
|
||||||
path = Path(image_path)
|
path = Path(image_path)
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
@@ -620,7 +871,7 @@ def set_cover_image(
|
|||||||
extension = path.suffix.lstrip(".").lower() or "jpg"
|
extension = path.suffix.lstrip(".").lower() or "jpg"
|
||||||
with _client() as client, path.open("rb") as handle:
|
with _client() as client, path.open("rb") as handle:
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/recipes/{slug_or_id}/image",
|
f"/api/recipes/{slug}/image",
|
||||||
files={"image": (path.name, handle)},
|
files={"image": (path.name, handle)},
|
||||||
data={"extension": extension},
|
data={"extension": extension},
|
||||||
)
|
)
|
||||||
@@ -630,7 +881,7 @@ def set_cover_image(
|
|||||||
used_url = source_url
|
used_url = source_url
|
||||||
method = "scrape"
|
method = "scrape"
|
||||||
try:
|
try:
|
||||||
_request("POST", f"/api/recipes/{slug_or_id}/image", json={"url": used_url})
|
_request("POST", f"/api/recipes/{slug}/image", json={"url": used_url})
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
if exc.response.status_code != 400 or "not an image" not in exc.response.text:
|
if exc.response.status_code != 400 or "not an image" not in exc.response.text:
|
||||||
raise
|
raise
|
||||||
@@ -640,10 +891,10 @@ def set_cover_image(
|
|||||||
f"{source_url} is not an image and no og:image was found on the page; "
|
f"{source_url} is not an image and no og:image was found on the page; "
|
||||||
"pass a direct image URL or use image_path"
|
"pass a direct image URL or use image_path"
|
||||||
) from exc
|
) from exc
|
||||||
_request("POST", f"/api/recipes/{slug_or_id}/image", json={"url": used_url})
|
_request("POST", f"/api/recipes/{slug}/image", json={"url": used_url})
|
||||||
method = "scrape (resolved og:image)"
|
method = "scrape (resolved og:image)"
|
||||||
|
|
||||||
recipe = get_recipe(slug_or_id)
|
recipe = get_recipe(slug)
|
||||||
result = {"method": method, "image": recipe.get("image"), "slug": recipe.get("slug")}
|
result = {"method": method, "image": recipe.get("image"), "slug": recipe.get("slug")}
|
||||||
if not image_path and used_url != source_url:
|
if not image_path and used_url != source_url:
|
||||||
result["resolved_image_url"] = used_url
|
result["resolved_image_url"] = used_url
|
||||||
@@ -776,3 +1027,13 @@ def main_read_only() -> None:
|
|||||||
"""Run the same server without import, patch, delete, parse, image, or HA writes."""
|
"""Run the same server without import, patch, delete, parse, image, or HA writes."""
|
||||||
_configure_read_only(mcp)
|
_configure_read_only(mcp)
|
||||||
mcp.run()
|
mcp.run()
|
||||||
|
|
||||||
|
|
||||||
|
def main_import() -> None:
|
||||||
|
"""Run the server with the import pipeline but without delete or HA writes.
|
||||||
|
|
||||||
|
Adding a recipe is recoverable through the Mealie UI; removing one is not,
|
||||||
|
so `delete_recipe` stays out even though this profile writes.
|
||||||
|
"""
|
||||||
|
_configure_read_only(mcp, IMPORT_TOOL_NAMES)
|
||||||
|
mcp.run()
|
||||||
|
|||||||
@@ -179,6 +179,21 @@ def verify_recipe(
|
|||||||
else:
|
else:
|
||||||
checks.append(_check("taxonomy", "fail", "No categories or tags, and no skip reason given"))
|
checks.append(_check("taxonomy", "fail", "No categories or tags, and no skip reason given"))
|
||||||
|
|
||||||
|
nutrition = {k: v for k, v in (recipe.get("nutrition") or {}).items() if v}
|
||||||
|
servings = recipe.get("recipeServings") or 0
|
||||||
|
if not servings:
|
||||||
|
# Mealie's figures are per serving, so nutrition without a serving count
|
||||||
|
# is a number without a denominator.
|
||||||
|
nutrition_status: Status = "fail"
|
||||||
|
nutrition_detail = "No recipeServings, so per-serving nutrition cannot be read"
|
||||||
|
elif not nutrition:
|
||||||
|
nutrition_status = "fail"
|
||||||
|
nutrition_detail = "No nutrition values"
|
||||||
|
else:
|
||||||
|
nutrition_status = "pass"
|
||||||
|
nutrition_detail = f"{len(nutrition)} value(s) per serving, {servings:g} serving(s)"
|
||||||
|
checks.append(_check("nutrition", nutrition_status, nutrition_detail))
|
||||||
|
|
||||||
source = recipe.get("orgURL") or (recipe.get("extras") or {}).get("source")
|
source = recipe.get("orgURL") or (recipe.get("extras") or {}).get("source")
|
||||||
checks.append(
|
checks.append(
|
||||||
_check(
|
_check(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ dev = ["pytest>=8.0"]
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
mealie-mcp = "mealie_mcp.server:main"
|
mealie-mcp = "mealie_mcp.server:main"
|
||||||
mealie-mcp-read-only = "mealie_mcp.server:main_read_only"
|
mealie-mcp-read-only = "mealie_mcp.server:main_read_only"
|
||||||
|
mealie-mcp-import = "mealie_mcp.server:main_import"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Server-layer behaviour, driven through a mocked HTTP transport."""
|
"""Server-layer behaviour, driven through a mocked HTTP transport."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -125,6 +127,118 @@ class TestTextImport:
|
|||||||
|
|
||||||
assert server.import_recipe_text(text)["recipe"] == recipe
|
assert server.import_recipe_text(text)["recipe"] == recipe
|
||||||
|
|
||||||
|
def test_text_is_never_posted_to_the_scraper_endpoint(self, mock_mealie, monkeypatch):
|
||||||
|
"""The scraper answers 400 for anything without schema.org markup.
|
||||||
|
|
||||||
|
Plain Swedish text has none, so every text import through
|
||||||
|
``/api/recipes/create/html-or-json`` failed. Pin the endpoints instead of
|
||||||
|
the payload, because that is what the mocked-out tests could not see.
|
||||||
|
"""
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path == "/api/recipes/create/html-or-json":
|
||||||
|
return httpx.Response(400, json={"detail": "no recipe data found"})
|
||||||
|
if request.method == "POST":
|
||||||
|
return httpx.Response(201, json="testsoppa")
|
||||||
|
return httpx.Response(200, json={"slug": "testsoppa"})
|
||||||
|
|
||||||
|
recorded = mock_mealie(handler)
|
||||||
|
monkeypatch.setattr(server, "get_recipe", lambda _slug: {"slug": "testsoppa"})
|
||||||
|
|
||||||
|
server.import_recipe_text(
|
||||||
|
"Titel: Testsoppa\n\nPortioner: 2\n\nIngredienser:\n1 dl vatten\n\n"
|
||||||
|
"Gör så här:\nBlanda."
|
||||||
|
)
|
||||||
|
|
||||||
|
paths = [(r.method, r.url.path) for r in recorded]
|
||||||
|
assert ("POST", "/api/recipes") in paths
|
||||||
|
assert ("PATCH", "/api/recipes/testsoppa") in paths
|
||||||
|
assert all(path != "/api/recipes/create/html-or-json" for _method, path in paths)
|
||||||
|
|
||||||
|
def test_sections_become_readable_lines_steps_and_yield(self, mock_mealie, monkeypatch):
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "POST":
|
||||||
|
return httpx.Response(201, json="testsoppa")
|
||||||
|
return httpx.Response(200, json={"slug": "testsoppa"})
|
||||||
|
|
||||||
|
recorded = mock_mealie(handler)
|
||||||
|
monkeypatch.setattr(server, "get_recipe", lambda _slug: {"slug": "testsoppa"})
|
||||||
|
|
||||||
|
server.import_recipe_text(
|
||||||
|
"Titel: Testsoppa\n\nPortioner: 4-6\n\nIngredienser:\n1 gul lök\n2 dl riven ost\n\n"
|
||||||
|
"Gör så här:\n1. Hacka löken.\n2. Rör ner osten.",
|
||||||
|
source_url="https://example.test/klipp",
|
||||||
|
)
|
||||||
|
|
||||||
|
create = json.loads(recorded[0].content)
|
||||||
|
patch = json.loads(recorded[1].content)
|
||||||
|
assert create == {"name": "Testsoppa"}
|
||||||
|
assert [item["note"] for item in patch["recipeIngredient"]] == ["1 gul lök", "2 dl riven ost"]
|
||||||
|
assert [step["text"] for step in patch["recipeInstructions"]] == [
|
||||||
|
"Hacka löken.",
|
||||||
|
"Rör ner osten.",
|
||||||
|
]
|
||||||
|
assert patch["recipeYield"] == "4-6"
|
||||||
|
assert patch["orgURL"] == "https://example.test/klipp"
|
||||||
|
# A range takes its lower bound; without a number Mealie cannot scale
|
||||||
|
# ingredients or show nutrition per serving.
|
||||||
|
assert patch["recipeServings"] == 4
|
||||||
|
|
||||||
|
def test_a_yield_without_a_number_leaves_servings_alone(self, mock_mealie, monkeypatch):
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "POST":
|
||||||
|
return httpx.Response(201, json="testsoppa")
|
||||||
|
return httpx.Response(200, json={"slug": "testsoppa"})
|
||||||
|
|
||||||
|
recorded = mock_mealie(handler)
|
||||||
|
monkeypatch.setattr(server, "get_recipe", lambda _slug: {"slug": "testsoppa"})
|
||||||
|
|
||||||
|
server.import_recipe_text(
|
||||||
|
"Titel: Testsoppa\n\nPortioner: en stor kastrull\n\nIngredienser:\n1 dl vatten\n\n"
|
||||||
|
"Gör så här:\nBlanda."
|
||||||
|
)
|
||||||
|
|
||||||
|
patch = json.loads(recorded[1].content)
|
||||||
|
assert patch["recipeYield"] == "en stor kastrull"
|
||||||
|
assert "recipeServings" not in patch
|
||||||
|
|
||||||
|
def test_a_marked_reconstruction_heading_is_still_a_heading(self, mock_mealie, monkeypatch):
|
||||||
|
"""``Gör så här (REKONSTRUERAD):`` marks the source, not the section name.
|
||||||
|
|
||||||
|
The old exact-match check rejected it, which is how a correctly marked
|
||||||
|
import got turned away before it reached Mealie.
|
||||||
|
"""
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "POST":
|
||||||
|
return httpx.Response(201, json="testsoppa")
|
||||||
|
return httpx.Response(200, json={"slug": "testsoppa"})
|
||||||
|
|
||||||
|
recorded = mock_mealie(handler)
|
||||||
|
monkeypatch.setattr(server, "get_recipe", lambda _slug: {"slug": "testsoppa"})
|
||||||
|
|
||||||
|
server.import_recipe_text(
|
||||||
|
"Titel: Testsoppa\n\nIngredienser:\n1 dl vatten\n\n"
|
||||||
|
"Gör så här (REKONSTRUERAD - står INTE i källan):\nBlanda."
|
||||||
|
)
|
||||||
|
|
||||||
|
patch = json.loads(recorded[1].content)
|
||||||
|
assert [step["text"] for step in patch["recipeInstructions"]] == ["Blanda."]
|
||||||
|
|
||||||
|
def test_a_failed_content_patch_names_the_empty_recipe_it_left(
|
||||||
|
self, mock_mealie, monkeypatch
|
||||||
|
):
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "POST":
|
||||||
|
return httpx.Response(201, json="testsoppa")
|
||||||
|
return httpx.Response(500, json={"detail": "boom"})
|
||||||
|
|
||||||
|
mock_mealie(handler)
|
||||||
|
monkeypatch.setattr(server, "get_recipe", lambda _slug: pytest.fail("patch failed"))
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="testsoppa"):
|
||||||
|
server.import_recipe_text(
|
||||||
|
"Titel: Testsoppa\n\nIngredienser:\n1 dl vatten\n\nGör så här:\nBlanda."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteRecipe:
|
class TestDeleteRecipe:
|
||||||
def test_requires_the_exact_stored_slug(self, monkeypatch):
|
def test_requires_the_exact_stored_slug(self, monkeypatch):
|
||||||
@@ -244,7 +358,166 @@ class TestParseIngredients:
|
|||||||
assert result["canonical_lines"] == ["2 dl grädde"]
|
assert result["canonical_lines"] == ["2 dl grädde"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrganizerPatch:
|
||||||
|
"""Tags and categories must reach Mealie as full RecipeTag/RecipeCategory objects.
|
||||||
|
|
||||||
|
Verified against the instance's OpenAPI schema (v3.22.0): both require ``name``
|
||||||
|
*and* ``slug``, so a bare ``{"name": ...}`` is answered with 422.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _mealie(self, mock_mealie, existing, recipe=None):
|
||||||
|
recipe = recipe or {"id": "stable-id", "slug": "soppa"}
|
||||||
|
bodies: list[dict] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
path = request.url.path
|
||||||
|
if path.startswith("/api/organizers/"):
|
||||||
|
if request.method == "GET":
|
||||||
|
return httpx.Response(200, json={"items": existing})
|
||||||
|
created = json.loads(request.content)
|
||||||
|
made = {"id": "new-id", "name": created["name"], "slug": "kalla-tiktok"}
|
||||||
|
bodies.append({"created": made})
|
||||||
|
return httpx.Response(201, json=made)
|
||||||
|
if request.method == "PATCH":
|
||||||
|
bodies.append({"patch": json.loads(request.content)})
|
||||||
|
return httpx.Response(200, json=recipe)
|
||||||
|
|
||||||
|
return bodies, mock_mealie(handler)
|
||||||
|
|
||||||
|
def test_a_bare_name_is_created_and_patched_with_its_slug(self, mock_mealie):
|
||||||
|
bodies, _ = self._mealie(mock_mealie, existing=[])
|
||||||
|
|
||||||
|
server.patch_recipe("soppa", {"tags": [{"name": "Källa: TikTok"}]})
|
||||||
|
|
||||||
|
created = [b["created"] for b in bodies if "created" in b]
|
||||||
|
patch = next(b["patch"] for b in bodies if "patch" in b)
|
||||||
|
assert created == [{"id": "new-id", "name": "Källa: TikTok", "slug": "kalla-tiktok"}]
|
||||||
|
assert patch["tags"] == created
|
||||||
|
|
||||||
|
def test_an_existing_organizer_is_reused_instead_of_forking_the_vocabulary(self, mock_mealie):
|
||||||
|
existing = [{"id": "tag-1", "name": "Källa: TikTok", "slug": "kalla-tiktok"}]
|
||||||
|
bodies, recorded = self._mealie(mock_mealie, existing=existing)
|
||||||
|
|
||||||
|
server.patch_recipe("soppa", {"tags": ["källa: tiktok"]})
|
||||||
|
|
||||||
|
patch = next(b["patch"] for b in bodies if "patch" in b)
|
||||||
|
assert patch["tags"] == existing
|
||||||
|
assert not any(r.method == "POST" for r in recorded)
|
||||||
|
|
||||||
|
def test_a_complete_object_is_passed_through_untouched(self, mock_mealie):
|
||||||
|
tag = {"id": "tag-1", "name": "📅 Vardag", "slug": "vardag"}
|
||||||
|
bodies, recorded = self._mealie(mock_mealie, existing=[])
|
||||||
|
|
||||||
|
server.patch_recipe("soppa", {"tags": [tag], "description": "Av: Matprinsessan"})
|
||||||
|
|
||||||
|
patch = next(b["patch"] for b in bodies if "patch" in b)
|
||||||
|
assert patch == {"tags": [tag], "description": "Av: Matprinsessan"}
|
||||||
|
assert not any(r.url.path.startswith("/api/organizers/") for r in recorded)
|
||||||
|
|
||||||
|
def test_categories_go_through_their_own_endpoint(self, mock_mealie):
|
||||||
|
_, recorded = self._mealie(mock_mealie, existing=[])
|
||||||
|
|
||||||
|
server.patch_recipe("soppa", {"recipeCategory": ["Såser"]})
|
||||||
|
|
||||||
|
lookups = [r.url.path for r in recorded if r.url.path.startswith("/api/organizers/")]
|
||||||
|
assert lookups == ["/api/organizers/categories", "/api/organizers/categories"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestNutritionPatch:
|
||||||
|
"""Mealie validates nothing here: it stores strings and renders them as fact."""
|
||||||
|
|
||||||
|
def _mealie(self, mock_mealie, recipe):
|
||||||
|
sent: list[dict] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "PATCH":
|
||||||
|
sent.append(json.loads(request.content))
|
||||||
|
return httpx.Response(200, json=recipe)
|
||||||
|
|
||||||
|
mock_mealie(handler)
|
||||||
|
return sent
|
||||||
|
|
||||||
|
def test_a_consistent_estimate_is_normalized_to_bare_numbers(self, mock_mealie):
|
||||||
|
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 4})
|
||||||
|
|
||||||
|
server.patch_recipe(
|
||||||
|
"sas",
|
||||||
|
{"nutrition": {
|
||||||
|
"calories": "459 kcal",
|
||||||
|
"proteinContent": 13,
|
||||||
|
"fatContent": "38 g",
|
||||||
|
"carbohydrateContent": 15.4,
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sent[0]["nutrition"] == {
|
||||||
|
"calories": "459",
|
||||||
|
"proteinContent": "13",
|
||||||
|
"fatContent": "38",
|
||||||
|
"carbohydrateContent": "15",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_energy_that_contradicts_the_macros_is_refused(self, mock_mealie):
|
||||||
|
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 4})
|
||||||
|
|
||||||
|
# 13 g protein + 38 g fat + 15 g carbohydrate is ~455 kcal, not 150.
|
||||||
|
with pytest.raises(ValueError, match="does not match the macros"):
|
||||||
|
server.patch_recipe(
|
||||||
|
"sas",
|
||||||
|
{"nutrition": {
|
||||||
|
"calories": 150,
|
||||||
|
"proteinContent": 13,
|
||||||
|
"fatContent": 38,
|
||||||
|
"carbohydrateContent": 15,
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
def test_nutrition_without_a_serving_count_is_refused(self, mock_mealie):
|
||||||
|
# The TikTok import left recipeServings 0 and only a "4-6 personer" string,
|
||||||
|
# so per-serving figures would have had nothing to divide by.
|
||||||
|
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 0})
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="recipeServings"):
|
||||||
|
server.patch_recipe("sas", {"nutrition": {"calories": 459}})
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
def test_servings_patched_in_the_same_call_satisfy_the_requirement(self, mock_mealie):
|
||||||
|
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 0})
|
||||||
|
|
||||||
|
server.patch_recipe("sas", {"recipeServings": 4, "nutrition": {"calories": 459}})
|
||||||
|
|
||||||
|
assert sent[0]["recipeServings"] == 4
|
||||||
|
assert sent[0]["nutrition"] == {"calories": "459"}
|
||||||
|
|
||||||
|
def test_a_misspelt_field_names_the_right_one_instead_of_vanishing(self, mock_mealie):
|
||||||
|
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 4})
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="carbohydrateContent"):
|
||||||
|
server.patch_recipe("sas", {"nutrition": {"carbs": 15}})
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
|
||||||
class TestCoverImage:
|
class TestCoverImage:
|
||||||
|
def test_an_id_is_resolved_to_the_slug_before_hitting_the_image_route(self, mock_mealie):
|
||||||
|
# Verified on v3.22.0: /api/recipes/{slug} takes an id, but the sub-routes
|
||||||
|
# under it are slug-only — an id answered 404 on POST .../image and 500 on
|
||||||
|
# PUT .../image, which is what silently killed the TikTok cover.
|
||||||
|
recipe_id = "1488dd5c-abbe-4660-b61b-eeca3f6cfb91"
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path.endswith("/image"):
|
||||||
|
assert request.url.path == "/api/recipes/skolans-ost-broccolisas/image"
|
||||||
|
return httpx.Response(200, json=None)
|
||||||
|
return httpx.Response(200, json={"slug": "skolans-ost-broccolisas", "image": "img"})
|
||||||
|
|
||||||
|
recorded = mock_mealie(handler)
|
||||||
|
|
||||||
|
result = server.set_cover_image(recipe_id, source_url="https://cdn.test/cover.jpg")
|
||||||
|
|
||||||
|
assert result["slug"] == "skolans-ost-broccolisas"
|
||||||
|
assert not any(recipe_id in str(r.url) and r.url.path.endswith("/image") for r in recorded)
|
||||||
|
|
||||||
def test_local_upload_sends_the_required_extension_field(self, mock_mealie, tmp_path):
|
def test_local_upload_sends_the_required_extension_field(self, mock_mealie, tmp_path):
|
||||||
# Verified: without the multipart `extension` field the upload fails validation.
|
# Verified: without the multipart `extension` field the upload fails validation.
|
||||||
image = tmp_path / "cover.webp"
|
image = tmp_path / "cover.webp"
|
||||||
|
|||||||
@@ -60,6 +60,19 @@ def test_read_only_tool_allowlist_is_explicit():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_profile_adds_the_pipeline_but_never_delete():
|
||||||
|
assert server.IMPORT_TOOL_NAMES == server.READ_ONLY_TOOL_NAMES | {
|
||||||
|
"import_recipe_url",
|
||||||
|
"import_recipe_text",
|
||||||
|
"import_recipe_image",
|
||||||
|
"patch_recipe",
|
||||||
|
"parse_ingredients",
|
||||||
|
"set_cover_image",
|
||||||
|
}
|
||||||
|
assert "delete_recipe" not in server.IMPORT_TOOL_NAMES
|
||||||
|
assert "shopping_list_add" not in server.IMPORT_TOOL_NAMES
|
||||||
|
|
||||||
|
|
||||||
class TestUrlImportResponse:
|
class TestUrlImportResponse:
|
||||||
def test_slug_only_import_response_is_resolved_before_reporting(self, monkeypatch):
|
def test_slug_only_import_response_is_resolved_before_reporting(self, monkeypatch):
|
||||||
recipe = {"slug": "lax-med-citron", "name": "Lax med citron", "recipeIngredient": []}
|
recipe = {"slug": "lax-med-citron", "name": "Lax med citron", "recipeIngredient": []}
|
||||||
@@ -106,7 +119,9 @@ class TestPatchContract:
|
|||||||
"id": "stable-id",
|
"id": "stable-id",
|
||||||
"slug": "ny-soppa",
|
"slug": "ny-soppa",
|
||||||
"name": "Ny soppa",
|
"name": "Ny soppa",
|
||||||
"tags": [{"name": "📅 Vardag"}],
|
# A complete organizer object, the shape Mealie's schema requires:
|
||||||
|
# name alone is 422, and resolving it is covered in TestOrganizerPatch.
|
||||||
|
"tags": [{"id": "tag-1", "name": "📅 Vardag", "slug": "vardag"}],
|
||||||
}
|
}
|
||||||
|
|
||||||
def request(method, path, **kwargs):
|
def request(method, path, **kwargs):
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ def complete_recipe(**overrides):
|
|||||||
"recipeCategory": [{"name": "Huvudrätter"}],
|
"recipeCategory": [{"name": "Huvudrätter"}],
|
||||||
"tags": [{"name": "Källa: Köket"}],
|
"tags": [{"name": "Källa: Köket"}],
|
||||||
"orgURL": "https://www.koket.se/kycklinggryta",
|
"orgURL": "https://www.koket.se/kycklinggryta",
|
||||||
|
"recipeServings": 4,
|
||||||
|
"nutrition": {"calories": "520", "proteinContent": "31", "fatContent": "34",
|
||||||
|
"carbohydrateContent": "18"},
|
||||||
}
|
}
|
||||||
recipe.update(overrides)
|
recipe.update(overrides)
|
||||||
return recipe
|
return recipe
|
||||||
@@ -130,6 +133,22 @@ class TestTaxonomy:
|
|||||||
assert report["complete"] is True
|
assert report["complete"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestNutrition:
|
||||||
|
def test_missing_nutrition_fails(self):
|
||||||
|
report = verify_recipe(complete_recipe(nutrition={}), image_verified=True)
|
||||||
|
assert status_of(report, "nutrition") == "fail"
|
||||||
|
assert report["complete"] is False
|
||||||
|
|
||||||
|
def test_values_without_a_serving_count_fail(self):
|
||||||
|
# Mealie shows nutrition per serving, so figures with recipeServings 0
|
||||||
|
# are a numerator without a denominator — the state the TikTok import left.
|
||||||
|
report = verify_recipe(complete_recipe(recipeServings=0), image_verified=True)
|
||||||
|
assert status_of(report, "nutrition") == "fail"
|
||||||
|
assert "recipeServings" in next(
|
||||||
|
c["detail"] for c in report["checks"] if c["check"] == "nutrition"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestAttribution:
|
class TestAttribution:
|
||||||
def test_missing_source_warns_but_does_not_block(self):
|
def test_missing_source_warns_but_does_not_block(self):
|
||||||
report = verify_recipe(
|
report = verify_recipe(
|
||||||
|
|||||||
Reference in New Issue
Block a user