From fe9dadefea02299e04b0b4e051bcd36efaa4ad20 Mon Sep 17 00:00:00 2001 From: Michael Genson Date: Sun, 22 Feb 2026 02:19:14 +0000 Subject: [PATCH] inject known standardized units upon unit creation --- mealie/repos/repository_units.py | 101 +++++++++++++++++++++- mealie/schema/recipe/recipe_ingredient.py | 23 +++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/mealie/repos/repository_units.py b/mealie/repos/repository_units.py index b9e3a1496..e42d7cb6d 100644 --- a/mealie/repos/repository_units.py +++ b/mealie/repos/repository_units.py @@ -1,17 +1,114 @@ -from pydantic import UUID4 +from collections.abc import Iterable + +from pydantic import UUID4, BaseModel from sqlalchemy import select from mealie.db.models.recipe.ingredient import IngredientUnitModel -from mealie.schema.recipe.recipe_ingredient import IngredientUnit +from mealie.lang.providers import get_locale_context +from mealie.schema.recipe.recipe_ingredient import IngredientUnit, StandardizedUnitType from .repository_generic import GroupRepositoryGeneric class RepositoryUnit(GroupRepositoryGeneric[IngredientUnit, IngredientUnitModel]): + _standardized_unit_map: dict[str, str] | None = None + + @property + def standardized_unit_map(self) -> dict[str, str]: + """A map of potential known units to its standardized name in our seed data""" + + if self._standardized_unit_map is None: + from .seed.seeders import IngredientUnitsSeeder + + ctx = get_locale_context() + if ctx: + locale = ctx[1].key + else: + locale = None + + self._standardized_unit_map = {} + locale_file = IngredientUnitsSeeder.get_file(locale=locale) + for unit_key, unit in IngredientUnitsSeeder.load_file(locale_file).items(): + for prop in ["name", "plural_name", "abbreviation"]: + val = unit.get(prop) + if val and isinstance(val, str): + self._standardized_unit_map[val.strip().lower()] = unit_key + + return self._standardized_unit_map + def _get_unit(self, id: UUID4) -> IngredientUnitModel: stmt = select(self.model).filter_by(**self._filter_builder(**{"id": id})) return self.session.execute(stmt).scalars().one() + def _add_standardized_unit(self, data: BaseModel | dict) -> dict: + if not isinstance(data, dict): + data = data.model_dump() + + for prop in ["name", "plural_name", "abbreviation", "plural_abbreviation"]: + val = data.get(prop) + if not (val and isinstance(val, str)): + continue + + standardized_unit_key = self.standardized_unit_map.get(val.strip().lower()) + if not standardized_unit_key: + continue + + match standardized_unit_key: + case "teaspoon": + data["standard_quantity"] = 1 / 6 + data["standard_unit"] = StandardizedUnitType.FLUID_OUNCE + case "tablespoon": + data["standard_quantity"] = 1 / 2 + data["standard_unit"] = StandardizedUnitType.FLUID_OUNCE + case "cup": + data["standard_quantity"] = 1 + data["standard_unit"] = StandardizedUnitType.CUP + case "fluid-ounce": + data["standard_quantity"] = 1 + data["standard_unit"] = StandardizedUnitType.FLUID_OUNCE + case "pint": + data["standard_quantity"] = 2 + data["standard_unit"] = StandardizedUnitType.CUP + case "quart": + data["standard_quantity"] = 4 + data["standard_unit"] = StandardizedUnitType.CUP + case "gallon": + data["standard_quantity"] = 16 + data["standard_unit"] = StandardizedUnitType.CUP + case "milliliter": + data["standard_quantity"] = 1 + data["standard_unit"] = StandardizedUnitType.MILLILITER + case "liter": + data["standard_quantity"] = 1 + data["standard_unit"] = StandardizedUnitType.LITER + case "pound": + data["standard_quantity"] = 1 + data["standard_unit"] = StandardizedUnitType.POUND + case "ounce": + data["standard_quantity"] = 1 + data["standard_unit"] = StandardizedUnitType.OUNCE + case "gram": + data["standard_quantity"] = 1 + data["standard_unit"] = StandardizedUnitType.GRAM + case "kilogram": + data["standard_quantity"] = 1 + data["standard_unit"] = StandardizedUnitType.KILOGRAM + case "milligram": + data["standard_quantity"] = 1 / 1000 + data["standard_unit"] = StandardizedUnitType.GRAM + case _: + continue + + return data + + def create(self, data: IngredientUnit | dict) -> IngredientUnit: + data = self._add_standardized_unit(data) + return super().create(data) + + def create_many(self, data: Iterable[IngredientUnit | dict]) -> list[IngredientUnit]: + data = [self._add_standardized_unit(i) for i in data] + return super().create_many(data) + def merge(self, from_unit: UUID4, to_unit: UUID4) -> IngredientUnit | None: from_model = self._get_unit(from_unit) to_model = self._get_unit(to_unit) diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index c171c42f1..d95f56c5b 100644 --- a/mealie/schema/recipe/recipe_ingredient.py +++ b/mealie/schema/recipe/recipe_ingredient.py @@ -2,6 +2,7 @@ from __future__ import annotations import datetime import enum +from enum import StrEnum from fractions import Fraction from typing import ClassVar from uuid import UUID, uuid4 @@ -34,6 +35,28 @@ def display_fraction(fraction: Fraction): ) +class StandardizedUnitType(StrEnum): + """ + An arbitrary list of standardized units supported by unit conversions. + The backend doesn't really care what standardized unit you use, as long as it's recognized, + but defining them here keeps it consistant with the frontend. + """ + + # Imperial + FLUID_OUNCE = "fluid ounce" + CUP = "cup" + + OUNCE = "ounce" + POUND = "pound" + + # Metric + MILLILITER = "milliliter" + LITER = "liter" + + GRAM = "gram" + KILOGRAM = "kilogram" + + class UnitFoodBase(MealieModel): id: UUID4 | None = None name: str