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 000000000000..4eed898879c3 Binary files /dev/null and b/tests/data/migrations/tandoor.zip differ 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", ]