Write text imports through the recipe API, not the scraper
import_recipe_text posted plain Swedish text to /api/recipes/create/html-or-json. That endpoint is a scraper entry point: Mealie runs recipe_scrapers over whatever `data` holds and answers 400 for anything without schema.org markup. A text block never had any, so the tool returned 400 on every call it has ever made — including a minimal one-line test recipe. The seven session logs of the importing agent contain no successful text import; every recipe that landed came in through import_recipe_url. The existing test mocked _request away, so it asserted the payload shape and never that Mealie would accept it. Green test, broken tool. Create the recipe with POST /api/recipes and fill it with a PATCH instead. Both are already exercised against this instance. Ingredients are stored as readable `note` lines, which is what parse_ingredients expects to structure afterwards. Also accept a parenthetical on a section heading. `Gör så här (REKONSTRUERAD):` marks where the steps came from, and the old exact-match check turned that import away before it reached Mealie. A PATCH that fails now leaves a name-only recipe, so the error names the slug rather than leaving a silent empty stub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+73
-9
@@ -307,6 +307,33 @@ 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()]
|
||||||
|
|
||||||
|
|
||||||
@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).
|
||||||
@@ -314,23 +341,60 @@ 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"]
|
||||||
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)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,97 @@ 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"
|
||||||
|
|
||||||
|
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):
|
||||||
|
|||||||
Reference in New Issue
Block a user