From 0f896107f934d5d628fabfca78de9399ea916abb Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Sun, 23 Jul 2023 12:52:09 -0500 Subject: [PATCH] feat: Migrate from Tandoor (#2438) * added tandoor migration to backend * added tandoor migration to frontend * updated tests * ignore 0 amounts * refactored ingredient display calculation * fix parsing tandoor recipes with optional data * generated frontend types * fixed inconsistent default handling and from_orm * removed unused imports --- .../recipes/use-recipe-ingredients.ts | 2 + frontend/lang/messages/en-US.json | 7 +- frontend/lib/api/types/group.ts | 193 ++++++++++++------ frontend/lib/api/types/recipe.ts | 17 +- frontend/pages/group/migrations.vue | 44 ++++ mealie/routes/groups/controller_migrations.py | 2 + mealie/schema/group/__init__.py | 46 ++--- mealie/schema/group/group_migration.py | 1 + mealie/schema/group/group_shopping_list.py | 84 +------- mealie/schema/recipe/__init__.py | 90 ++++---- mealie/schema/recipe/recipe.py | 19 ++ mealie/schema/recipe/recipe_ingredient.py | 128 +++++++++++- mealie/services/migrations/__init__.py | 1 + mealie/services/migrations/tandoor.py | 147 +++++++++++++ mealie/services/scraper/cleaner.py | 10 +- tests/data/__init__.py | 2 + tests/data/migrations/tandoor.zip | Bin 0 -> 30746 bytes .../test_recipe_migrations.py | 2 + 18 files changed, 559 insertions(+), 236 deletions(-) create mode 100644 mealie/services/migrations/tandoor.py create mode 100644 tests/data/migrations/tandoor.zip diff --git a/frontend/composables/recipes/use-recipe-ingredients.ts b/frontend/composables/recipes/use-recipe-ingredients.ts index 66e16c981717..ca0d93531fd2 100644 --- a/frontend/composables/recipes/use-recipe-ingredients.ts +++ b/frontend/composables/recipes/use-recipe-ingredients.ts @@ -11,6 +11,8 @@ function sanitizeIngredientHTML(rawHtml: string) { } export function parseIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1): string { + // TODO: the backend now supplies a "display" property which does this for us, so we don't need this function + if (disableAmount) { return ingredient.note || ""; } diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index 569d95d7edb8..b319b5497180 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -332,6 +332,10 @@ "description-long": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.", "title": "Mealie Pre v1.0" }, + "tandoor": { + "description-long": "Mealie can import recipes from Tandoor. Export your data in the \"Default\" format, then upload the .zip below.", + "title": "Tandoor Recipes" + }, "recipe-data-migrations": "Recipe Data Migrations", "recipe-data-migrations-explanation": "Recipes can be migrated from another supported application to Mealie. This is a great way to get started with Mealie.", "choose-migration-type": "Choose Migration Type", @@ -341,8 +345,7 @@ "recipe-1": "Recipe 1", "recipe-2": "Recipe 2", "paprika-text": "Mealie can import recipes from the Paprika application. Export your recipes from paprika, rename the export extension to .zip and upload it below.", - "mealie-text": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.", - "previous-migrations": "Previous Migrations" + "mealie-text": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export." }, "new-recipe": { "bulk-add": "Bulk Add", diff --git a/frontend/lib/api/types/group.ts b/frontend/lib/api/types/group.ts index 1f448dc0a1ba..a0dd0517e00b 100644 --- a/frontend/lib/api/types/group.ts +++ b/frontend/lib/api/types/group.ts @@ -6,7 +6,7 @@ */ export type WebhookType = "mealplan"; -export type SupportedMigrations = "nextcloud" | "chowdown" | "copymethat" | "paprika" | "mealie_alpha"; +export type SupportedMigrations = "nextcloud" | "chowdown" | "copymethat" | "paprika" | "mealie_alpha" | "tandoor"; export interface CreateGroupPreferences { privateGroup?: boolean; @@ -247,71 +247,43 @@ export interface SetPermissions { } export interface ShoppingListAddRecipeParams { recipeIncrementQuantity?: number; + recipeIngredients?: RecipeIngredient[]; } -export interface ShoppingListCreate { - name?: string; - extras?: { - [k: string]: unknown; - }; - createdAt?: string; - updateAt?: string; -} -export interface ShoppingListItemBase { - shoppingListId: string; - checked?: boolean; - position?: number; - isFood?: boolean; - note?: string; +export interface RecipeIngredient { quantity?: number; - foodId?: string; - labelId?: string; - unitId?: string; - extras?: { - [k: string]: unknown; - }; -} -export interface ShoppingListItemCreate { - shoppingListId: string; - checked?: boolean; - position?: number; - isFood?: boolean; + unit?: IngredientUnit | CreateIngredientUnit; + food?: IngredientFood | CreateIngredientFood; note?: string; - quantity?: number; - foodId?: string; - labelId?: string; - unitId?: string; - extras?: { - [k: string]: unknown; - }; - recipeReferences?: ShoppingListItemRecipeRefCreate[]; -} -export interface ShoppingListItemRecipeRefCreate { - recipeId: string; - recipeQuantity?: number; - recipeScale?: number; -} -export interface ShoppingListItemOut { - shoppingListId: string; - checked?: boolean; - position?: number; isFood?: boolean; - note?: string; - quantity?: number; - foodId?: string; - labelId?: string; - unitId?: string; - extras?: { - [k: string]: unknown; - }; - id: string; + disableAmount?: boolean; display?: string; - food?: IngredientFood; - label?: MultiPurposeLabelSummary; - unit?: IngredientUnit; - recipeReferences?: ShoppingListItemRecipeRefOut[]; + title?: string; + originalText?: string; + referenceId?: string; +} +export interface IngredientUnit { + name: string; + description?: string; + extras?: { + [k: string]: unknown; + }; + fraction?: boolean; + abbreviation?: string; + useAbbreviation?: boolean; + id: string; createdAt?: string; updateAt?: string; } +export interface CreateIngredientUnit { + name: string; + description?: string; + extras?: { + [k: string]: unknown; + }; + fraction?: boolean; + abbreviation?: string; + useAbbreviation?: boolean; +} export interface IngredientFood { name: string; description?: string; @@ -330,16 +302,84 @@ export interface MultiPurposeLabelSummary { groupId: string; id: string; } -export interface IngredientUnit { +export interface CreateIngredientFood { name: string; description?: string; extras?: { [k: string]: unknown; }; - fraction?: boolean; - abbreviation?: string; - useAbbreviation?: boolean; + labelId?: string; +} +export interface ShoppingListCreate { + name?: string; + extras?: { + [k: string]: unknown; + }; + createdAt?: string; + updateAt?: string; +} +export interface ShoppingListItemBase { + quantity?: number; + unit?: IngredientUnit | CreateIngredientUnit; + food?: IngredientFood | CreateIngredientFood; + note?: string; + isFood?: boolean; + disableAmount?: boolean; + display?: string; + shoppingListId: string; + checked?: boolean; + position?: number; + foodId?: string; + labelId?: string; + unitId?: string; + extras?: { + [k: string]: unknown; + }; +} +export interface ShoppingListItemCreate { + quantity?: number; + unit?: IngredientUnit | CreateIngredientUnit; + food?: IngredientFood | CreateIngredientFood; + note?: string; + isFood?: boolean; + disableAmount?: boolean; + display?: string; + shoppingListId: string; + checked?: boolean; + position?: number; + foodId?: string; + labelId?: string; + unitId?: string; + extras?: { + [k: string]: unknown; + }; + recipeReferences?: ShoppingListItemRecipeRefCreate[]; +} +export interface ShoppingListItemRecipeRefCreate { + recipeId: string; + recipeQuantity?: number; + recipeScale?: number; +} +export interface ShoppingListItemOut { + quantity?: number; + unit?: IngredientUnit; + food?: IngredientFood; + note?: string; + isFood?: boolean; + disableAmount?: boolean; + display?: string; + shoppingListId: string; + checked?: boolean; + position?: number; + foodId?: string; + labelId?: string; + unitId?: string; + extras?: { + [k: string]: unknown; + }; id: string; + label?: MultiPurposeLabelSummary; + recipeReferences?: ShoppingListItemRecipeRefOut[]; createdAt?: string; updateAt?: string; } @@ -358,12 +398,16 @@ export interface ShoppingListItemRecipeRefUpdate { shoppingListItemId: string; } export interface ShoppingListItemUpdate { + quantity?: number; + unit?: IngredientUnit | CreateIngredientUnit; + food?: IngredientFood | CreateIngredientFood; + note?: string; + isFood?: boolean; + disableAmount?: boolean; + display?: string; shoppingListId: string; checked?: boolean; position?: number; - isFood?: boolean; - note?: string; - quantity?: number; foodId?: string; labelId?: string; unitId?: string; @@ -376,12 +420,16 @@ export interface ShoppingListItemUpdate { * Only used for bulk update operations where the shopping list item id isn't already supplied */ export interface ShoppingListItemUpdateBulk { + quantity?: number; + unit?: IngredientUnit | CreateIngredientUnit; + food?: IngredientFood | CreateIngredientFood; + note?: string; + isFood?: boolean; + disableAmount?: boolean; + display?: string; shoppingListId: string; checked?: boolean; position?: number; - isFood?: boolean; - note?: string; - quantity?: number; foodId?: string; labelId?: string; unitId?: string; @@ -512,3 +560,12 @@ export interface ShoppingListUpdate { id: string; listItems?: ShoppingListItemOut[]; } +export interface RecipeIngredientBase { + quantity?: number; + unit?: IngredientUnit | CreateIngredientUnit; + food?: IngredientFood | CreateIngredientFood; + note?: string; + isFood?: boolean; + disableAmount?: boolean; + display?: string; +} diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts index e99b691d767d..a281dcd0b603 100644 --- a/frontend/lib/api/types/recipe.ts +++ b/frontend/lib/api/types/recipe.ts @@ -178,12 +178,14 @@ export interface ParsedIngredient { ingredient: RecipeIngredient; } export interface RecipeIngredient { - title?: string; - note?: string; + quantity?: number; unit?: IngredientUnit | CreateIngredientUnit; food?: IngredientFood | CreateIngredientFood; + note?: string; + isFood?: boolean; disableAmount?: boolean; - quantity?: number; + display?: string; + title?: string; originalText?: string; referenceId?: string; } @@ -303,6 +305,15 @@ export interface RecipeCommentUpdate { export interface RecipeDuplicate { name?: string; } +export interface RecipeIngredientBase { + quantity?: number; + unit?: IngredientUnit | CreateIngredientUnit; + food?: IngredientFood | CreateIngredientFood; + note?: string; + isFood?: boolean; + disableAmount?: boolean; + display?: string; +} export interface RecipeLastMade { timestamp: string; } diff --git a/frontend/pages/group/migrations.vue b/frontend/pages/group/migrations.vue index 5606ad04e9a9..724ff6173c10 100644 --- a/frontend/pages/group/migrations.vue +++ b/frontend/pages/group/migrations.vue @@ -80,6 +80,7 @@ const MIGRATIONS = { copymethat: "copymethat", paprika: "paprika", mealie: "mealie_alpha", + tandoor: "tandoor", }; export default defineComponent({ @@ -118,6 +119,10 @@ export default defineComponent({ text: i18n.tc("migration.mealie-pre-v1.title"), value: MIGRATIONS.mealie, }, + { + text: i18n.tc("migration.tandoor.title"), + value: MIGRATIONS.tandoor, + }, ]; const _content = { @@ -267,6 +272,45 @@ export default defineComponent({ }, ], }, + [MIGRATIONS.tandoor]: { + text: i18n.tc("migration.tandoor.description-long"), + tree: [ + { + id: 1, + icon: $globals.icons.zip, + name: "tandoor_default_export_full_2023-06-29.zip", + children: [ + { + id: 2, + name: "1.zip", + icon: $globals.icons.zip, + children: [ + { id: 3, name: "image.jpeg", icon: $globals.icons.fileImage }, + { id: 4, name: "recipe.json", icon: $globals.icons.codeJson }, + ] + }, + { + id: 5, + name: "2.zip", + icon: $globals.icons.zip, + children: [ + { id: 6, name: "image.jpeg", icon: $globals.icons.fileImage }, + { id: 7, name: "recipe.json", icon: $globals.icons.codeJson }, + ] + }, + { + id: 8, + name: "3.zip", + icon: $globals.icons.zip, + children: [ + { id: 9, name: "image.jpeg", icon: $globals.icons.fileImage }, + { id: 10, name: "recipe.json", icon: $globals.icons.codeJson }, + ] + } + ] + } + ], + }, }; function setFileObject(fileObject: File) { diff --git a/mealie/routes/groups/controller_migrations.py b/mealie/routes/groups/controller_migrations.py index 76ebfea7526a..06a4ebb4f1d8 100644 --- a/mealie/routes/groups/controller_migrations.py +++ b/mealie/routes/groups/controller_migrations.py @@ -16,6 +16,7 @@ from mealie.services.migrations import ( MealieAlphaMigrator, NextcloudMigrator, PaprikaMigrator, + TandoorMigrator, ) router = UserAPIRouter(prefix="/groups/migrations", tags=["Group: Migrations"]) @@ -50,6 +51,7 @@ class GroupMigrationController(BaseUserController): SupportedMigrations.mealie_alpha: MealieAlphaMigrator, SupportedMigrations.nextcloud: NextcloudMigrator, SupportedMigrations.paprika: PaprikaMigrator, + SupportedMigrations.tandoor: TandoorMigrator, } constructor = table.get(migration_type, None) diff --git a/mealie/schema/group/__init__.py b/mealie/schema/group/__init__.py index 87b5dd3a7ddb..18cb0f97226e 100644 --- a/mealie/schema/group/__init__.py +++ b/mealie/schema/group/__init__.py @@ -14,11 +14,7 @@ from .group_events import ( from .group_exports import GroupDataExport from .group_migration import DataMigrationCreate, SupportedMigrations from .group_permissions import SetPermissions -from .group_preferences import ( - CreateGroupPreferences, - ReadGroupPreferences, - UpdateGroupPreferences, -) +from .group_preferences import CreateGroupPreferences, ReadGroupPreferences, UpdateGroupPreferences from .group_seeder import SeederConfig from .group_shopping_list import ( ShoppingListAddRecipeParams, @@ -26,6 +22,7 @@ from .group_shopping_list import ( ShoppingListItemBase, ShoppingListItemCreate, ShoppingListItemOut, + ShoppingListItemPagination, ShoppingListItemRecipeRefCreate, ShoppingListItemRecipeRefOut, ShoppingListItemRecipeRefUpdate, @@ -44,31 +41,11 @@ from .group_shopping_list import ( ShoppingListUpdate, ) from .group_statistics import GroupStatistics, GroupStorage -from .invite_token import ( - CreateInviteToken, - EmailInitationResponse, - EmailInvitation, - ReadInviteToken, - SaveInviteToken, -) -from .webhook import ( - CreateWebhook, - ReadWebhook, - SaveWebhook, - WebhookPagination, - WebhookType, -) +from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken +from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType __all__ = [ - "CreateGroupPreferences", - "ReadGroupPreferences", - "UpdateGroupPreferences", - "GroupDataExport", - "CreateWebhook", - "ReadWebhook", - "SaveWebhook", - "WebhookPagination", - "WebhookType", + "GroupAdminUpdate", "GroupEventNotifierCreate", "GroupEventNotifierOptions", "GroupEventNotifierOptionsOut", @@ -78,14 +55,20 @@ __all__ = [ "GroupEventNotifierSave", "GroupEventNotifierUpdate", "GroupEventPagination", + "GroupDataExport", "DataMigrationCreate", "SupportedMigrations", + "SetPermissions", + "CreateGroupPreferences", + "ReadGroupPreferences", + "UpdateGroupPreferences", "SeederConfig", "ShoppingListAddRecipeParams", "ShoppingListCreate", "ShoppingListItemBase", "ShoppingListItemCreate", "ShoppingListItemOut", + "ShoppingListItemPagination", "ShoppingListItemRecipeRefCreate", "ShoppingListItemRecipeRefOut", "ShoppingListItemRecipeRefUpdate", @@ -102,8 +85,6 @@ __all__ = [ "ShoppingListSave", "ShoppingListSummary", "ShoppingListUpdate", - "GroupAdminUpdate", - "SetPermissions", "GroupStatistics", "GroupStorage", "CreateInviteToken", @@ -111,4 +92,9 @@ __all__ = [ "EmailInvitation", "ReadInviteToken", "SaveInviteToken", + "CreateWebhook", + "ReadWebhook", + "SaveWebhook", + "WebhookPagination", + "WebhookType", ] diff --git a/mealie/schema/group/group_migration.py b/mealie/schema/group/group_migration.py index 6e02825142aa..ece0ffb5d9a8 100644 --- a/mealie/schema/group/group_migration.py +++ b/mealie/schema/group/group_migration.py @@ -9,6 +9,7 @@ class SupportedMigrations(str, enum.Enum): copymethat = "copymethat" paprika = "paprika" mealie_alpha = "mealie_alpha" + tandoor = "tandoor" class DataMigrationCreate(MealieModel): diff --git a/mealie/schema/group/group_shopping_list.py b/mealie/schema/group/group_shopping_list.py index 7896b9503883..82d4c27d1b14 100644 --- a/mealie/schema/group/group_shopping_list.py +++ b/mealie/schema/group/group_shopping_list.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import datetime -from fractions import Fraction from pydantic import UUID4, validator from sqlalchemy.orm import joinedload, selectinload @@ -20,25 +19,13 @@ from mealie.schema.getter_dict import ExtrasGetterDict from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary from mealie.schema.recipe.recipe import RecipeSummary from mealie.schema.recipe.recipe_ingredient import ( - INGREDIENT_QTY_PRECISION, - MAX_INGREDIENT_DENOMINATOR, IngredientFood, IngredientUnit, RecipeIngredient, + RecipeIngredientBase, ) from mealie.schema.response.pagination import PaginationBase -SUPERSCRIPT = dict(zip("1234567890", "¹²³⁴⁵⁶⁷⁸⁹⁰", strict=False)) -SUBSCRIPT = dict(zip("1234567890", "₁₂₃₄₅₆₇₈₉₀", strict=False)) - - -def display_fraction(fraction: Fraction): - return ( - "".join([SUPERSCRIPT[c] for c in str(fraction.numerator)]) - + "/" - + "".join([SUBSCRIPT[c] for c in str(fraction.denominator)]) - ) - class ShoppingListItemRecipeRefCreate(MealieModel): recipe_id: UUID4 @@ -63,20 +50,18 @@ class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRefUpdate): orm_mode = True -class ShoppingListItemBase(MealieModel): +class ShoppingListItemBase(RecipeIngredientBase): shopping_list_id: UUID4 checked: bool = False position: int = 0 - is_food: bool = False - - note: str | None = "" quantity: float = 1 food_id: UUID4 | None = None label_id: UUID4 | None = None unit_id: UUID4 | None = None + is_food: bool = False extras: dict | None = {} @@ -96,12 +81,6 @@ class ShoppingListItemUpdateBulk(ShoppingListItemUpdate): class ShoppingListItemOut(ShoppingListItemBase): id: UUID4 - display: str = "" - """ - How the ingredient should be displayed - - Automatically calculated after the object is created - """ food: IngredientFood | None label: MultiPurposeLabelSummary | None @@ -120,63 +99,6 @@ class ShoppingListItemOut(ShoppingListItemBase): self.label = self.food.label self.label_id = self.label.id - # format the display property - if not self.display: - self.display = self._format_display() - - def _format_quantity_for_display(self) -> str: - """How the quantity should be displayed""" - - qty: float | Fraction - - # decimal - if not self.unit or not self.unit.fraction: - qty = round(self.quantity, INGREDIENT_QTY_PRECISION) - if qty.is_integer(): - return str(int(qty)) - - else: - return str(qty) - - # fraction - qty = Fraction(self.quantity).limit_denominator(MAX_INGREDIENT_DENOMINATOR) - if qty.denominator == 1: - return str(qty.numerator) - - if qty.numerator <= qty.denominator: - return display_fraction(qty) - - # convert an improper fraction into a mixed fraction (e.g. 11/4 --> 2 3/4) - whole_number = 0 - while qty.numerator > qty.denominator: - whole_number += 1 - qty -= 1 - - return f"{whole_number} {display_fraction(qty)}" - - def _format_display(self) -> str: - components = [] - - # ingredients with no food come across with a qty of 1, which looks weird - # e.g. "1 2 tbsp of olive oil" - if self.quantity and (self.is_food or self.quantity != 1): - components.append(self._format_quantity_for_display()) - - if not self.is_food: - components.append(self.note or "") - - else: - if self.quantity and self.unit: - components.append(self.unit.abbreviation if self.unit.use_abbreviation else self.unit.name) - - if self.food: - components.append(self.food.name) - - if self.note: - components.append(self.note) - - return " ".join(components) - class Config: orm_mode = True getter_dict = ExtrasGetterDict diff --git a/mealie/schema/recipe/__init__.py b/mealie/schema/recipe/__init__.py index 7473d59633ee..272ce0a11244 100644 --- a/mealie/schema/recipe/__init__.py +++ b/mealie/schema/recipe/__init__.py @@ -6,6 +6,7 @@ from .recipe import ( Recipe, RecipeCategory, RecipeCategoryPagination, + RecipeLastMade, RecipePagination, RecipeSummary, RecipeTag, @@ -58,6 +59,7 @@ from .recipe_ingredient import ( MergeUnit, ParsedIngredient, RecipeIngredient, + RecipeIngredientBase, RegisteredParser, SaveIngredientFood, SaveIngredientUnit, @@ -81,28 +83,27 @@ from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, Re from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse __all__ = [ - "RecipeToolCreate", - "RecipeToolOut", - "RecipeToolResponse", - "RecipeToolSave", - "RecipeTimelineEventCreate", - "RecipeTimelineEventIn", - "RecipeTimelineEventOut", - "RecipeTimelineEventPagination", - "RecipeTimelineEventUpdate", - "TimelineEventType", + "CreateRecipe", + "CreateRecipeBulk", + "CreateRecipeByUrlBulk", + "Recipe", + "RecipeCategory", + "RecipeCategoryPagination", + "RecipeLastMade", + "RecipePagination", + "RecipeSummary", + "RecipeTag", + "RecipeTagPagination", + "RecipeTool", + "RecipeToolPagination", "RecipeAsset", - "RecipeSettings", - "RecipeShareToken", - "RecipeShareTokenCreate", - "RecipeShareTokenSave", - "RecipeShareTokenSummary", - "RecipeDuplicate", - "RecipeSlug", - "RecipeZipTokenResponse", - "SlugResponse", - "UpdateImageResponse", - "RecipeNote", + "AssignCategories", + "AssignSettings", + "AssignTags", + "DeleteRecipes", + "ExportBase", + "ExportRecipes", + "ExportTypes", "CategoryBase", "CategoryIn", "CategoryOut", @@ -119,17 +120,7 @@ __all__ = [ "RecipeCommentSave", "RecipeCommentUpdate", "UserBase", - "AssignCategories", - "AssignSettings", - "AssignTags", - "DeleteRecipes", - "ExportBase", - "ExportRecipes", - "ExportTypes", - "IngredientReferences", - "RecipeStep", "RecipeImageTypes", - "Nutrition", "CreateIngredientFood", "CreateIngredientUnit", "IngredientConfidence", @@ -143,22 +134,35 @@ __all__ = [ "MergeUnit", "ParsedIngredient", "RecipeIngredient", + "RecipeIngredientBase", "RegisteredParser", "SaveIngredientFood", "SaveIngredientUnit", "UnitFoodBase", - "CreateRecipe", - "CreateRecipeBulk", - "CreateRecipeByUrlBulk", - "Recipe", - "RecipeCategory", - "RecipeCategoryPagination", - "RecipePagination", - "RecipeSummary", - "RecipeTag", - "RecipeTagPagination", - "RecipeTool", - "RecipeToolPagination", + "RecipeNote", + "Nutrition", "ScrapeRecipe", "ScrapeRecipeTest", + "RecipeSettings", + "RecipeShareToken", + "RecipeShareTokenCreate", + "RecipeShareTokenSave", + "RecipeShareTokenSummary", + "IngredientReferences", + "RecipeStep", + "RecipeTimelineEventCreate", + "RecipeTimelineEventIn", + "RecipeTimelineEventOut", + "RecipeTimelineEventPagination", + "RecipeTimelineEventUpdate", + "TimelineEventType", + "RecipeToolCreate", + "RecipeToolOut", + "RecipeToolResponse", + "RecipeToolSave", + "RecipeDuplicate", + "RecipeSlug", + "RecipeZipTokenResponse", + "SlugResponse", + "UpdateImageResponse", ] diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index 94027d407fa4..33a0b7e01c15 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -157,6 +157,25 @@ class Recipe(RecipeSummary): orm_mode = True getter_dict = ExtrasGetterDict + @classmethod + def from_orm(cls, obj): + recipe = super().from_orm(obj) + recipe.__post_init__() + return recipe + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.__post_init__() + + def __post_init__(self) -> None: + # the ingredient disable_amount property is unreliable, + # so we set it here and recalculate the display property + disable_amount = self.settings.disable_amount if self.settings else True + for ingredient in self.recipe_ingredient: + ingredient.disable_amount = disable_amount + ingredient.is_food = not ingredient.disable_amount + ingredient.display = ingredient._format_display() + @validator("slug", always=True, pre=True, allow_reuse=True) def validate_slug(slug: str, values): # type: ignore if not values.get("name"): diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index aaffd193ba97..ef4facb60ad2 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 fractions import Fraction from uuid import UUID, uuid4 from pydantic import UUID4, Field, validator @@ -17,6 +18,17 @@ from mealie.schema.response.pagination import PaginationBase INGREDIENT_QTY_PRECISION = 3 MAX_INGREDIENT_DENOMINATOR = 32 +SUPERSCRIPT = dict(zip("1234567890", "¹²³⁴⁵⁶⁷⁸⁹⁰", strict=False)) +SUBSCRIPT = dict(zip("1234567890", "₁₂₃₄₅₆₇₈₉₀", strict=False)) + + +def display_fraction(fraction: Fraction): + return ( + "".join([SUPERSCRIPT[c] for c in str(fraction.numerator)]) + + "/" + + "".join([SUBSCRIPT[c] for c in str(fraction.denominator)]) + ) + class UnitFoodBase(MealieModel): name: str @@ -70,18 +82,119 @@ class IngredientUnit(CreateIngredientUnit): orm_mode = True +class RecipeIngredientBase(MealieModel): + quantity: NoneFloat = 1 + unit: IngredientUnit | CreateIngredientUnit | None + food: IngredientFood | CreateIngredientFood | None + note: str | None = "" + + is_food: bool | None = None + disable_amount: bool | None = None + display: str = "" + """ + How the ingredient should be displayed + + Automatically calculated after the object is created, unless overwritten + """ + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + # calculate missing is_food and disable_amount values + # we can't do this in a validator since they depend on each other + if self.is_food is None and self.disable_amount is not None: + self.is_food = not self.disable_amount + elif self.disable_amount is None and self.is_food is not None: + self.disable_amount = not self.is_food + elif self.is_food is None and self.disable_amount is None: + self.is_food = bool(self.food) + self.disable_amount = not self.is_food + + # format the display property + if not self.display: + self.display = self._format_display() + + @validator("unit", pre=True) + def validate_unit(cls, v): + if isinstance(v, str): + return CreateIngredientUnit(name=v) + else: + return v + + @validator("food", pre=True) + def validate_food(cls, v): + if isinstance(v, str): + return CreateIngredientFood(name=v) + else: + return v + + def _format_quantity_for_display(self) -> str: + """How the quantity should be displayed""" + + qty: float | Fraction + + # decimal + if not self.unit or not self.unit.fraction: + qty = round(self.quantity or 0, INGREDIENT_QTY_PRECISION) + if qty.is_integer(): + return str(int(qty)) + + else: + return str(qty) + + # fraction + qty = Fraction(self.quantity or 0).limit_denominator(MAX_INGREDIENT_DENOMINATOR) + if qty.denominator == 1: + return str(qty.numerator) + + if qty.numerator <= qty.denominator: + return display_fraction(qty) + + # convert an improper fraction into a mixed fraction (e.g. 11/4 --> 2 3/4) + whole_number = 0 + while qty.numerator > qty.denominator: + whole_number += 1 + qty -= 1 + + return f"{whole_number} {display_fraction(qty)}" + + def _format_display(self) -> str: + components = [] + + use_food = True + if self.is_food is False: + use_food = False + elif self.disable_amount is True: + use_food = False + + # ingredients with no food come across with a qty of 1, which looks weird + # e.g. "1 2 tbsp of olive oil" + if self.quantity and (use_food or self.quantity != 1): + components.append(self._format_quantity_for_display()) + + if not use_food: + components.append(self.note or "") + else: + if self.quantity and self.unit: + components.append(self.unit.abbreviation if self.unit.use_abbreviation else self.unit.name) + + if self.food: + components.append(self.food.name) + + if self.note: + components.append(self.note) + + return " ".join(components) + + class IngredientUnitPagination(PaginationBase): items: list[IngredientUnit] -class RecipeIngredient(MealieModel): +class RecipeIngredient(RecipeIngredientBase): title: str | None - note: str | None - unit: IngredientUnit | CreateIngredientUnit | None - food: IngredientFood | CreateIngredientFood | None - disable_amount: bool = True - quantity: NoneFloat = 1 original_text: str | None + disable_amount: bool = True # Ref is used as a way to distinguish between an individual ingredient on the frontend # It is required for the reorder and section titles to function properly because of how @@ -92,8 +205,7 @@ class RecipeIngredient(MealieModel): orm_mode = True @validator("quantity", pre=True) - @classmethod - def validate_quantity(cls, value, values) -> NoneFloat: + def validate_quantity(cls, value) -> NoneFloat: """ Sometimes the frontend UI will provide an empty string as a "null" value because of the default bindings in Vue. This validator will ensure that the quantity is set to None if the value is an diff --git a/mealie/services/migrations/__init__.py b/mealie/services/migrations/__init__.py index 4610ff7705cc..9e8493133726 100644 --- a/mealie/services/migrations/__init__.py +++ b/mealie/services/migrations/__init__.py @@ -3,3 +3,4 @@ from .copymethat import * from .mealie_alpha import * from .nextcloud import * from .paprika import * +from .tandoor import * diff --git a/mealie/services/migrations/tandoor.py b/mealie/services/migrations/tandoor.py new file mode 100644 index 000000000000..366eb1615009 --- /dev/null +++ b/mealie/services/migrations/tandoor.py @@ -0,0 +1,147 @@ +import json +import os +import tempfile +import zipfile +from pathlib import Path +from typing import Any + +from mealie.schema.recipe.recipe_ingredient import RecipeIngredientBase +from mealie.schema.reports.reports import ReportEntryCreate + +from ._migration_base import BaseMigrator +from .utils.migration_alias import MigrationAlias +from .utils.migration_helpers import import_image + + +def _build_ingredient_from_ingredient_data(ingredient_data: dict[str, Any], title: str | None = None) -> dict[str, Any]: + quantity = ingredient_data.get("amount", "1") + if unit_data := ingredient_data.get("unit"): + unit = unit_data.get("plural_name") or unit_data.get("name") + else: + unit = None + + if food_data := ingredient_data.get("food"): + food = food_data.get("plural_name") or food_data.get("name") + else: + food = None + + base_ingredient = RecipeIngredientBase(quantity=quantity, unit=unit, food=food) + return {"title": title, "note": base_ingredient.display} + + +def extract_instructions_and_ingredients(steps: list[dict[str, Any]]) -> tuple[list[str], list[dict[str, Any]]]: + """Returns a list of instructions and ingredients for a recipe""" + + instructions: list[str] = [] + ingredients: list[dict[str, Any]] = [] + for step in steps: + if instruction_text := step.get("instruction"): + instructions.append(instruction_text) + if ingredients_data := step.get("ingredients"): + for i, ingredient in enumerate(ingredients_data): + if not i and (title := step.get("name")): + ingredients.append(_build_ingredient_from_ingredient_data(ingredient, title)) + else: + ingredients.append(_build_ingredient_from_ingredient_data(ingredient)) + + return instructions, ingredients + + +def _format_time(minutes: int) -> str: + # TODO: make this translatable + hour_label = "hour" + hours_label = "hours" + minute_label = "minute" + minutes_label = "minutes" + + hours, minutes = divmod(minutes, 60) + parts: list[str] = [] + + if hours: + parts.append(f"{int(hours)} {hour_label if hours == 1 else hours_label}") + if minutes: + parts.append(f"{minutes} {minute_label if minutes == 1 else minutes_label}") + + return " ".join(parts) + + +def parse_times(working_time: int, waiting_time: int) -> tuple[str, str]: + """Returns the performTime and totalTime""" + + total_time = working_time + waiting_time + return _format_time(working_time), _format_time(total_time) + + +class TandoorMigrator(BaseMigrator): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.name = "tandoor" + + self.key_aliases = [ + MigrationAlias(key="tags", alias="keywords", func=lambda kws: [kw["name"] for kw in kws if kw.get("name")]), + MigrationAlias(key="orgURL", alias="source_url", func=None), + ] + + def _process_recipe_document(self, source_dir: Path, recipe_data: dict) -> dict: + steps_data = recipe_data.pop("steps", []) + recipe_data["recipeInstructions"], recipe_data["recipeIngredient"] = extract_instructions_and_ingredients( + steps_data + ) + recipe_data["performTime"], recipe_data["totalTime"] = parse_times( + recipe_data.pop("working_time", 0), recipe_data.pop("waiting_time", 0) + ) + + serving_size = recipe_data.pop("servings", 0) + serving_text = recipe_data.pop("servings_text", "") + if serving_size and serving_text: + recipe_data["recipeYield"] = f"{serving_size} {serving_text}" + + recipe_data["image"] = str(source_dir.joinpath("image.jpeg")) + return recipe_data + + def _migrate(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + with zipfile.ZipFile(self.archive) as zip_file: + zip_file.extractall(tmpdir) + + source_dir = Path(tmpdir) + + recipes_as_dicts: list[dict] = [] + for i, recipe_zip_file in enumerate(source_dir.glob("*.zip")): + try: + recipe_dir = str(source_dir.joinpath(f"recipe_{i+1}")) + os.makedirs(recipe_dir) + + with zipfile.ZipFile(recipe_zip_file) as recipe_zip: + recipe_zip.extractall(recipe_dir) + + recipe_source_dir = Path(recipe_dir) + recipe_json_path = recipe_source_dir.joinpath("recipe.json") + with open(recipe_json_path) as f: + recipes_as_dicts.append(self._process_recipe_document(recipe_source_dir, json.load(f))) + + except Exception as e: + self.report_entries.append( + ReportEntryCreate( + report_id=self.report_id, + success=False, + message="Failed to parse recipe", + exception=f"{type(e).__name__}: {e}", + ) + ) + + recipes = [self.clean_recipe_dictionary(x) for x in recipes_as_dicts] + results = self.import_recipes_to_database(recipes) + recipe_lookup = {r.slug: r for r in recipes} + for slug, recipe_id, status in results: + if status: + try: + r = recipe_lookup.get(slug) + if not r or not r.image: + continue + + except StopIteration: + continue + + import_image(r.image, recipe_id) diff --git a/mealie/services/scraper/cleaner.py b/mealie/services/scraper/cleaner.py index 2b6d54dfc1b6..1637564bfcaa 100644 --- a/mealie/services/scraper/cleaner.py +++ b/mealie/services/scraper/cleaner.py @@ -234,7 +234,7 @@ def _sanitize_instruction_text(line: str | dict) -> str: return clean_line -def clean_ingredients(ingredients: list | str | None, default: list | None = None) -> list[str]: +def clean_ingredients(ingredients: list | str | None, default: list | None = None) -> list[str | dict]: """ ingredient attempts to parse the ingredients field from a recipe and return a list of @@ -250,6 +250,14 @@ def clean_ingredients(ingredients: list | str | None, default: list | None = Non case None: return default or [] case list(ingredients): + cleaned_ingredients: list[str | dict] = [] + for ing in ingredients: + if isinstance(ing, dict): + cleaned_ingredients.append({clean_string(k): clean_string(v) for k, v in ing.items()}) + else: + cleaned_ingredients.append(clean_string(ing)) + return cleaned_ingredients + case [str()]: return [clean_string(ingredient) for ingredient in ingredients] case str(ingredients): return [clean_string(ingredient) for ingredient in ingredients.splitlines()] diff --git a/tests/data/__init__.py b/tests/data/__init__.py index 30be70f5ca5a..4ea2d4362408 100644 --- a/tests/data/__init__.py +++ b/tests/data/__init__.py @@ -14,6 +14,8 @@ migrations_mealie = CWD / "migrations/mealie.zip" migrations_nextcloud = CWD / "migrations/nextcloud.zip" +migrations_tandoor = CWD / "migrations/tandoor.zip" + images_test_image_1 = CWD / "images/test-image-1.jpg" images_test_image_2 = CWD / "images/test-image-2.png" diff --git a/tests/data/migrations/tandoor.zip b/tests/data/migrations/tandoor.zip new file mode 100644 index 0000000000000000000000000000000000000000..4eed898879c3a61e921be4441dcbdd238b38632f GIT binary patch literal 30746 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 literal 0 HcmV?d00001 diff --git a/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py b/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py index 94332149b6db..24be48ad136a 100644 --- a/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py +++ b/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py @@ -23,6 +23,7 @@ test_cases = [ MigrationTestData(typ=SupportedMigrations.chowdown, archive=test_data.migrations_chowdown), MigrationTestData(typ=SupportedMigrations.copymethat, archive=test_data.migrations_copymethat), MigrationTestData(typ=SupportedMigrations.mealie_alpha, archive=test_data.migrations_mealie), + MigrationTestData(typ=SupportedMigrations.tandoor, archive=test_data.migrations_tandoor), ] test_ids = [ @@ -31,6 +32,7 @@ test_ids = [ "chowdown_archive", "copymethat_archive", "mealie_alpha_archive", + "tandoor_archive", ]