mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-27 00:04:23 -04:00 
			
		
		
		
	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
This commit is contained in:
		| @@ -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 || ""; | ||||
|   } | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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", | ||||
| ] | ||||
|   | ||||
| @@ -9,6 +9,7 @@ class SupportedMigrations(str, enum.Enum): | ||||
|     copymethat = "copymethat" | ||||
|     paprika = "paprika" | ||||
|     mealie_alpha = "mealie_alpha" | ||||
|     tandoor = "tandoor" | ||||
|  | ||||
|  | ||||
| class DataMigrationCreate(MealieModel): | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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", | ||||
| ] | ||||
|   | ||||
| @@ -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"): | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -3,3 +3,4 @@ from .copymethat import * | ||||
| from .mealie_alpha import * | ||||
| from .nextcloud import * | ||||
| from .paprika import * | ||||
| from .tandoor import * | ||||
|   | ||||
							
								
								
									
										147
									
								
								mealie/services/migrations/tandoor.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								mealie/services/migrations/tandoor.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| @@ -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()] | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								tests/data/migrations/tandoor.zip
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								tests/data/migrations/tandoor.zip
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -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", | ||||
| ] | ||||
|  | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user