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:
Michael Genson 2023-07-23 12:52:09 -05:00 committed by GitHub
parent c25b58e404
commit 0f896107f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 559 additions and 236 deletions

View File

@ -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 || "";
}

View File

@ -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",

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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) {

View 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)

View File

@ -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",
]

View File

@ -9,6 +9,7 @@ class SupportedMigrations(str, enum.Enum):
copymethat = "copymethat"
paprika = "paprika"
mealie_alpha = "mealie_alpha"
tandoor = "tandoor"
class DataMigrationCreate(MealieModel):

View File

@ -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

View File

@ -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",
]

View File

@ -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"):

View File

@ -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

View File

@ -3,3 +3,4 @@ from .copymethat import *
from .mealie_alpha import *
from .nextcloud import *
from .paprika import *
from .tandoor import *

View 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)

View File

@ -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()]

View File

@ -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"

Binary file not shown.

View File

@ -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",
]