diff --git a/alembic/versions/2024-10-01-14.17.00_602927e1013e_add_the_rest_of_the_schema_org_.py b/alembic/versions/2024-10-01-14.17.00_602927e1013e_add_the_rest_of_the_schema_org_.py new file mode 100644 index 00000000000..dbeea910fa6 --- /dev/null +++ b/alembic/versions/2024-10-01-14.17.00_602927e1013e_add_the_rest_of_the_schema_org_.py @@ -0,0 +1,39 @@ +"""'add the rest of the schema.org nutrition properties' + +Revision ID: 602927e1013e +Revises: 1fe4bd37ccc8 +Create Date: 2024-10-01 14:17:00.611398 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "602927e1013e" +down_revision: str | None = "1fe4bd37ccc8" +branch_labels: str | tuple[str, ...] | None = None +depends_on: str | tuple[str, ...] | None = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("recipe_nutrition", schema=None) as batch_op: + batch_op.add_column(sa.Column("cholesterol_content", sa.String(), nullable=True)) + batch_op.add_column(sa.Column("saturated_fat_content", sa.String(), nullable=True)) + batch_op.add_column(sa.Column("trans_fat_content", sa.String(), nullable=True)) + batch_op.add_column(sa.Column("unsaturated_fat_content", sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("recipe_nutrition", schema=None) as batch_op: + batch_op.drop_column("unsaturated_fat_content") + batch_op.drop_column("trans_fat_content") + batch_op.drop_column("saturated_fat_content") + batch_op.drop_column("cholesterol_content") + + # ### end Alembic commands ### diff --git a/frontend/composables/recipes/use-recipe-nutrition.ts b/frontend/composables/recipes/use-recipe-nutrition.ts index a1e53b7502c..e249d5d612c 100644 --- a/frontend/composables/recipes/use-recipe-nutrition.ts +++ b/frontend/composables/recipes/use-recipe-nutrition.ts @@ -17,6 +17,14 @@ export function useNutritionLabels() { label: i18n.tc("recipe.calories"), suffix: i18n.tc("recipe.calories-suffix"), }, + carbohydrateContent: { + label: i18n.tc("recipe.carbohydrate-content"), + suffix: i18n.tc("recipe.grams"), + }, + cholesterolContent: { + label: i18n.tc("recipe.cholesterol-content"), + suffix: i18n.tc("recipe.milligrams"), + }, fatContent: { label: i18n.tc("recipe.fat-content"), suffix: i18n.tc("recipe.grams"), @@ -29,6 +37,10 @@ export function useNutritionLabels() { label: i18n.tc("recipe.protein-content"), suffix: i18n.tc("recipe.grams"), }, + saturatedFatContent: { + label: i18n.tc("recipe.saturated-fat-content"), + suffix: i18n.tc("recipe.grams"), + }, sodiumContent: { label: i18n.tc("recipe.sodium-content"), suffix: i18n.tc("recipe.milligrams"), @@ -37,8 +49,12 @@ export function useNutritionLabels() { label: i18n.tc("recipe.sugar-content"), suffix: i18n.tc("recipe.grams"), }, - carbohydrateContent: { - label: i18n.tc("recipe.carbohydrate-content"), + transFatContent: { + label: i18n.tc("recipe.trans-fat-content"), + suffix: i18n.tc("recipe.grams"), + }, + unsaturatedFatContent: { + label: i18n.tc("recipe.unsaturated-fat-content"), suffix: i18n.tc("recipe.grams"), }, }; diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index c53e551c5f4..93842b69fdf 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -461,6 +461,7 @@ "calories-suffix": "calories", "carbohydrate-content": "Carbohydrate", "categories": "Categories", + "cholesterol-content": "Cholesterol", "comment-action": "Comment", "comment": "Comment", "comments": "Comments", @@ -507,8 +508,10 @@ "recipe-updated": "Recipe updated", "remove-from-favorites": "Remove from Favorites", "remove-section": "Remove Section", + "saturated-fat-content": "Saturated fat", "save-recipe-before-use": "Save recipe before use", "section-title": "Section Title", + "serving-size": "Serving size", "servings": "Servings", "share-recipe-message": "I wanted to share my {0} recipe with you.", "show-nutrition-values": "Show Nutrition Values", @@ -517,7 +520,9 @@ "sugar-content": "Sugar", "title": "Title", "total-time": "Total Time", + "trans-fat-content": "Trans-fat", "unable-to-delete-recipe": "Unable to Delete Recipe", + "unsaturated-fat-content": "Unsaturated fat", "no-recipe": "No Recipe", "locked-by-owner": "Locked by Owner", "join-the-conversation": "Join the Conversation", diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts index 3125b6bced0..8973fd70f05 100644 --- a/frontend/lib/api/types/recipe.ts +++ b/frontend/lib/api/types/recipe.ts @@ -194,12 +194,16 @@ export interface MergeUnit { } export interface Nutrition { calories?: string | null; - fatContent?: string | null; - proteinContent?: string | null; carbohydrateContent?: string | null; + cholesterolContent?: string | null; + fatContent?: string | null; fiberContent?: string | null; + proteinContent?: string | null; + saturatedFatContent?: string | null; sodiumContent?: string | null; sugarContent?: string | null; + transFatContent?: string | null; + unsaturatedFatContent?: string | null; } export interface ParsedIngredient { input?: string | null; @@ -486,7 +490,7 @@ export interface ScrapeRecipeTest { url: string; useOpenAI?: boolean; } -export interface SlugResponse {} +export interface SlugResponse { } export interface TagIn { name: string; } diff --git a/mealie/db/models/recipe/nutrition.py b/mealie/db/models/recipe/nutrition.py index 1202a4a701d..61974838935 100644 --- a/mealie/db/models/recipe/nutrition.py +++ b/mealie/db/models/recipe/nutrition.py @@ -9,28 +9,52 @@ class Nutrition(SqlAlchemyBase): __tablename__ = "recipe_nutrition" id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True) + calories: Mapped[str | None] = mapped_column(sa.String) + carbohydrate_content: Mapped[str | None] = mapped_column(sa.String) + cholesterol_content: Mapped[str | None] = mapped_column(sa.String) fat_content: Mapped[str | None] = mapped_column(sa.String) fiber_content: Mapped[str | None] = mapped_column(sa.String) protein_content: Mapped[str | None] = mapped_column(sa.String) - carbohydrate_content: Mapped[str | None] = mapped_column(sa.String) + saturated_fat_content: Mapped[str | None] = mapped_column(sa.String) + + # `serving_size` is not a scaling factor, but a per-serving volume or mass + # according to schema.org. E.g., "2 L", "500 g", "5 cups", etc. + # + # Ignoring for now because it's too difficult to work around variable units + # in translation for the frontend. Also, it causes cognitive dissonance wrt + # "servings" (i.e., "serves 2" etc.), which is an unrelated concept that + # might cause confusion. + # + # serving_size: Mapped[str | None] = mapped_column(sa.String) + sodium_content: Mapped[str | None] = mapped_column(sa.String) sugar_content: Mapped[str | None] = mapped_column(sa.String) + trans_fat_content: Mapped[str | None] = mapped_column(sa.String) + unsaturated_fat_content: Mapped[str | None] = mapped_column(sa.String) def __init__( self, calories=None, + carbohydrate_content=None, + cholesterol_content=None, fat_content=None, fiber_content=None, protein_content=None, + saturated_fat_content=None, sodium_content=None, sugar_content=None, - carbohydrate_content=None, + trans_fat_content=None, + unsaturated_fat_content=None, ) -> None: self.calories = calories + self.carbohydrate_content = carbohydrate_content + self.cholesterol_content = cholesterol_content self.fat_content = fat_content self.fiber_content = fiber_content self.protein_content = protein_content + self.saturated_fat_content = saturated_fat_content self.sodium_content = sodium_content self.sugar_content = sugar_content - self.carbohydrate_content = carbohydrate_content + self.trans_fat_content = trans_fat_content + self.unsaturated_fat_content = unsaturated_fat_content diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 9266ec899f0..fc9c142bb9c 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -187,7 +187,7 @@ def __init__( settings: dict | None = None, **_, ) -> None: - self.nutrition = Nutrition(**nutrition) if nutrition else Nutrition() + self.nutrition = Nutrition(**(nutrition or {})) if recipe_instructions is not None: self.recipe_instructions = [RecipeInstruction(**step, session=session) for step in recipe_instructions] @@ -198,7 +198,7 @@ def __init__( if assets: self.assets = [RecipeAsset(**a) for a in assets] - self.settings = RecipeSettings(**settings) if settings else RecipeSettings() + self.settings = RecipeSettings(**(settings or {})) if notes: self.notes = [Note(**n) for n in notes] diff --git a/mealie/routes/spa/__init__.py b/mealie/routes/spa/__init__.py index 77af4747dc3..d4c21e4c050 100644 --- a/mealie/routes/spa/__init__.py +++ b/mealie/routes/spa/__init__.py @@ -104,15 +104,7 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str: ingredients.append(s) - nutrition: dict[str, str | None] = {} - if recipe.nutrition: - nutrition["calories"] = recipe.nutrition.calories - nutrition["fatContent"] = recipe.nutrition.fat_content - nutrition["fiberContent"] = recipe.nutrition.fiber_content - nutrition["proteinContent"] = recipe.nutrition.protein_content - nutrition["carbohydrateContent"] = recipe.nutrition.carbohydrate_content - nutrition["sodiumContent"] = recipe.nutrition.sodium_content - nutrition["sugarContent"] = recipe.nutrition.sugar_content + nutrition: dict[str, str | None] = recipe.nutrition.model_dump(by_alias=True) if recipe.nutrition else {} as_schema_org = { "@context": "https://schema.org", diff --git a/mealie/schema/recipe/recipe_nutrition.py b/mealie/schema/recipe/recipe_nutrition.py index 167b30285e4..920dfca7759 100644 --- a/mealie/schema/recipe/recipe_nutrition.py +++ b/mealie/schema/recipe/recipe_nutrition.py @@ -1,14 +1,24 @@ from pydantic import ConfigDict +from pydantic.alias_generators import to_camel from mealie.schema._mealie import MealieModel class Nutrition(MealieModel): calories: str | None = None - fat_content: str | None = None - protein_content: str | None = None carbohydrate_content: str | None = None + cholesterol_content: str | None = None + fat_content: str | None = None fiber_content: str | None = None + protein_content: str | None = None + saturated_fat_content: str | None = None sodium_content: str | None = None sugar_content: str | None = None - model_config = ConfigDict(from_attributes=True, coerce_numbers_to_str=True) + trans_fat_content: str | None = None + unsaturated_fat_content: str | None = None + + model_config = ConfigDict( + from_attributes=True, + coerce_numbers_to_str=True, + alias_generator=to_camel, + ) diff --git a/mealie/services/migrations/plantoeat.py b/mealie/services/migrations/plantoeat.py index 7dc1efc76ee..c9c9efdd3ef 100644 --- a/mealie/services/migrations/plantoeat.py +++ b/mealie/services/migrations/plantoeat.py @@ -74,6 +74,8 @@ def _parse_recipe_nutrition_from_row(self, row: dict) -> dict: nut_dict["sodiumContent"] = get_value_as_string_or_none(row, "Sodium") nut_dict["sugarContent"] = get_value_as_string_or_none(row, "Sugar") + # FIXME: do we have the other schema.org values here to migrate? + return cleaner.clean_nutrition(nut_dict) def _get_categories_from_row(self, row: dict) -> list[str]: