diff --git a/mealie_mcp/server.py b/mealie_mcp/server.py index 6fbbfa5..46629b4 100644 --- a/mealie_mcp/server.py +++ b/mealie_mcp/server.py @@ -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)} +_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() 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). @@ -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 roundup, a YouTube description, or a social caption. Build the text in Swedish 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") - missing = [ - heading - for heading in required - if not re.search(rf"(?im)^\s*{re.escape(heading)}\s*:", text) - ] + missing = [heading for heading in required if heading.lower() not in sections] if missing: raise ValueError( "Text import requires explicit Swedish section headings; 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: - payload["url"] = source_url - created = _request("POST", "/api/recipes/create/html-or-json", json=payload) - recipe = get_recipe(created) if isinstance(created, str) else created + patch["orgURL"] = source_url + + 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)} diff --git a/tests/test_server.py b/tests/test_server.py index 4cb2527..d135f1b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,6 +1,8 @@ """Server-layer behaviour, driven through a mocked HTTP transport.""" from __future__ import annotations +import json + import httpx import pytest @@ -125,6 +127,97 @@ class TestTextImport: 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: def test_requires_the_exact_stored_slug(self, monkeypatch):