"""Text normalization for Swedish recipe content. Pure functions, no I/O. These encode pitfalls verified against Fredrik's live Mealie instance — see resources/import-pitfalls.md. """ from __future__ import annotations import re from decimal import ROUND_HALF_UP, Decimal #: Campaign/navigation suffixes seen appended to imported titles, notably from Köket. TITLE_JUNK_SUFFIXES = ( "- se & gör", "- se och gör", "| Köket.se", "| Köket", "- Recept", "- recept", ) #: Placeholder text Mealie writes when a scrape fails to find real content. EXTRACTION_FAILURE_MARKERS = ( "Could not detect ingredients", "Could not detect instructions", ) #: English units/labels that must not survive into a finished Swedish recipe. ENGLISH_LEFTOVERS = ( "cup", "cups", "tbsp", "tsp", "tablespoon", "teaspoon", "oz", "ounce", "lb", "pound", "clove", "cloves", "servings", "ingredients", "instructions", ) _DECIMAL_COMMA = re.compile(r"(?<=\d),(?=\d)") _WHITESPACE = re.compile(r"\s+") def normalize_for_parser(line: str) -> str: """Return a variant of a Swedish ingredient line the Mealie parser handles better. Only converts decimal comma to decimal point (``4,7 dl`` -> ``4.7 dl``). The result is for the parser only; the original line stays canonical for ``display``/``note``, because the parser mangles Swedish text. """ return _WHITESPACE.sub(" ", _DECIMAL_COMMA.sub(".", line)).strip() def clean_title(title: str) -> str: """Strip campaign/navigation junk that import scrapers append to recipe titles.""" cleaned = title.strip() changed = True while changed: changed = False for suffix in TITLE_JUNK_SUFFIXES: if cleaned.lower().endswith(suffix.lower()): cleaned = cleaned[: -len(suffix)].strip(" -|–") changed = True return _WHITESPACE.sub(" ", cleaned).strip() def slugify(title: str) -> str: """Build a Mealie-style slug from a Swedish title, preserving å/ä/ö transliteration.""" text = title.lower() for source, target in (("å", "a"), ("ä", "a"), ("ö", "o"), ("é", "e"), ("&", "och")): text = text.replace(source, target) text = re.sub(r"[^a-z0-9]+", "-", text) return text.strip("-") def find_english_leftovers(text: str) -> list[str]: """Return English unit/label words still present in supposedly Swedish content.""" lowered = text.lower() return [word for word in ENGLISH_LEFTOVERS if re.search(rf"\b{re.escape(word)}\b", lowered)] def has_extraction_failure(recipe: dict) -> bool: """Detect Mealie's 'could not detect' placeholders anywhere in a recipe.""" haystack = [recipe.get("description") or ""] haystack += [i.get("display") or i.get("note") or "" for i in recipe.get("recipeIngredient") or []] haystack += [s.get("text") or "" for s in recipe.get("recipeInstructions") or []] blob = " ".join(haystack) return any(marker in blob for marker in EXTRACTION_FAILURE_MARKERS) def ingredient_display_lines(recipe: dict) -> list[str]: """Extract the human-facing ingredient lines, preferring display over note.""" lines = [] for item in recipe.get("recipeIngredient") or []: text = item.get("display") or item.get("note") or "" if not text: food = item.get("food") or {} text = food.get("name") or "" lines.append(text) return lines #: One amount: a decimal ("4,7"), a fraction ("1/2"), or a mixed number ("1 1/2"). _AMOUNT = r"\d+(?:[.,]\d+)?(?:\s+\d+/\d+)?|\d+/\d+" #: A leading amount in a Swedish ingredient line, optionally a range: #: "800 g ...", "4,7 dl ...", "1 klyfta ...", "1-1,5 msk ...", "1 1/2 msk ...". _LEADING_AMOUNT = re.compile( rf"^\s*(?P{_AMOUNT})(?:\s*[-–]\s*(?P{_AMOUNT}))?(?=\s|[^\d.,/-]|$)" ) def parse_amount(raw: str) -> Decimal: """Read a Swedish/ASCII amount: ``4,7``, ``1/2``, or the mixed number ``1 1/2``.""" text = raw.strip().replace(",", ".") whole = Decimal(0) if " " in text: head, text = text.split(None, 1) whole = Decimal(head) if "/" in text: numerator, denominator = text.split("/", 1) return whole + Decimal(numerator) / Decimal(denominator) return whole + Decimal(text) def format_amount(value: Decimal) -> str: """Render a scaled amount the way the recipe library writes them. Whole numbers lose the decimal part (``1200``, not ``1200.0``) and fractions use the Swedish decimal comma (``1,5``) to match the stored ingredient lines. """ quantized = value.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) if quantized == quantized.to_integral_value(): return str(int(quantized)) return format(quantized, "f").replace(".", ",") def scale_line(line: str, factor: Decimal) -> tuple[str, bool]: """Scale the leading amount of an ingredient line. Returns the line and whether anything was scaled. The amount is read from the display text rather than the structured ``quantity`` field, because unparsed recipes carry ``quantity: 0.0`` while the real number lives in the text — and because ``str(800.0)`` never substring-matches ``"800 g ..."``. """ match = _LEADING_AMOUNT.match(line) if not match: return line, False def scaled(raw: str) -> str: return format_amount(parse_amount(raw) * factor) low = scaled(match.group("low")) high = match.group("high") # A range must move at both ends: "1-1,5 msk" doubled is "2-3 msk", not "2-1,5 msk". replacement = f"{low}-{scaled(high)}" if high else low return line[: match.start("low")] + replacement + line[match.end() :], True #: The word directly after a leading amount — the unit candidate in a Swedish line. _UNIT_TOKEN = re.compile( rf"^\s*(?:{_AMOUNT})(?:\s*[-–]\s*(?:{_AMOUNT}))?\s+(?P[^\W\d_]+)", flags=re.UNICODE, ) def unit_token(line: str) -> str | None: """Return the word following the leading amount, or None if the line has no amount. This is the unit as *written* (``dl``, ``msk``, ``g``, ``klyfta``). It is the authority for which unit a row means, because the Mealie NLP parser resolves ``3 dl`` to the ``liter`` record — a verified 10x error on a live instance. Not every match is a unit (``0,5 citron`` yields ``citron``); callers confirm the token against the instance's own unit table. """ match = _UNIT_TOKEN.match(line) return match.group("token") if match else None def match_unit(token: str | None, units: list[dict]) -> dict | None: """Find the unit record a written token refers to, by name or abbreviation. Matches singular and plural forms of both. Returns None when the token is not a unit in this Mealie instance, which is the signal to store no unit at all rather than the parser's guess. """ if not token: return None needle = token.strip().lower() fields = ("name", "pluralName", "abbreviation", "pluralAbbreviation") for unit in units: if any((unit.get(field) or "").strip().lower() == needle for field in fields): return unit return None def food_matches_line(food_name: str, line: str) -> bool: """True when the parser's food is actually the ingredient the line names. The parser substitutes near neighbours from the existing food table — ``800 g kycklinglårfilé`` came back as the food ``kycklingfilé``. A food is accepted only when its name appears in the line outright (``koncentrerad kycklingfond``) or begins a word in it, which keeps the singular record ``körsbärstomat`` for ``körsbärstomater`` while rejecting the lår/filé swap. """ name = (food_name or "").strip().lower() if not name: return False # The name must begin a word, so `lök` does not match inside `vitlök`, while a # trailing plural (`körsbärstomat` in `körsbärstomater`) is still allowed. return re.search(rf"(? str: """Return what is left of a line once amount, unit, and food are held as structure. Mealie composes the visible row as ``quantity unit food note``, so ``note`` must hold only the leftover text. Passing the whole line duplicates it (``3 dl vispgrädde 3 dl vispgrädde``). The food match is widened to the end of the word it starts, so the record ``körsbärstomat`` consumes ``körsbärstomater`` and leaves ``(i olika färger)``. """ rest = _LEADING_AMOUNT.sub("", line, count=1) if written_unit: rest = re.sub(rf"^\s*{re.escape(written_unit)}\b", "", rest, count=1, flags=re.IGNORECASE) if food_name: match = re.search( rf"(? bool: """Compare two ingredient rows ignoring only whitespace differences.""" return _WHITESPACE.sub(" ", left or "").strip() == _WHITESPACE.sub(" ", right or "").strip() def looks_mangled(line: str) -> bool: """Heuristic for parser-damaged display text. Verified failure modes: vulgar/composed fractions (``47⁄10 liter``), duplicated units, and stray fraction glyphs the Mealie parser emits for Swedish rows. """ if any(glyph in line for glyph in ("⁄", "½", "¼", "¾", "⅓", "⅔")): return True # Duplicated words, but only alphabetic ones: "2 dl dl grädde" is parser damage, # while the mixed number in "1 1/2 msk tomatpuré" is perfectly readable. if re.search(r"\b([^\W\d_]+)\s+\1\b", line, flags=re.IGNORECASE): return True return False