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 { 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) { if (disableAmount) {
return ingredient.note || ""; 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.", "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" "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": "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.", "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", "choose-migration-type": "Choose Migration Type",
@ -341,8 +345,7 @@
"recipe-1": "Recipe 1", "recipe-1": "Recipe 1",
"recipe-2": "Recipe 2", "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.", "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.", "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"
}, },
"new-recipe": { "new-recipe": {
"bulk-add": "Bulk Add", "bulk-add": "Bulk Add",

View File

@ -6,7 +6,7 @@
*/ */
export type WebhookType = "mealplan"; 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 { export interface CreateGroupPreferences {
privateGroup?: boolean; privateGroup?: boolean;
@ -247,71 +247,43 @@ export interface SetPermissions {
} }
export interface ShoppingListAddRecipeParams { export interface ShoppingListAddRecipeParams {
recipeIncrementQuantity?: number; recipeIncrementQuantity?: number;
recipeIngredients?: RecipeIngredient[];
} }
export interface ShoppingListCreate { export interface RecipeIngredient {
name?: string;
extras?: {
[k: string]: unknown;
};
createdAt?: string;
updateAt?: string;
}
export interface ShoppingListItemBase {
shoppingListId: string;
checked?: boolean;
position?: number;
isFood?: boolean;
note?: string;
quantity?: number; quantity?: number;
foodId?: string; unit?: IngredientUnit | CreateIngredientUnit;
labelId?: string; food?: IngredientFood | CreateIngredientFood;
unitId?: string;
extras?: {
[k: string]: unknown;
};
}
export interface ShoppingListItemCreate {
shoppingListId: string;
checked?: boolean;
position?: number;
isFood?: boolean;
note?: string; 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; isFood?: boolean;
note?: string; disableAmount?: boolean;
quantity?: number;
foodId?: string;
labelId?: string;
unitId?: string;
extras?: {
[k: string]: unknown;
};
id: string;
display?: string; display?: string;
food?: IngredientFood; title?: string;
label?: MultiPurposeLabelSummary; originalText?: string;
unit?: IngredientUnit; referenceId?: string;
recipeReferences?: ShoppingListItemRecipeRefOut[]; }
export interface IngredientUnit {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string; createdAt?: string;
updateAt?: string; updateAt?: string;
} }
export interface CreateIngredientUnit {
name: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
}
export interface IngredientFood { export interface IngredientFood {
name: string; name: string;
description?: string; description?: string;
@ -330,16 +302,84 @@ export interface MultiPurposeLabelSummary {
groupId: string; groupId: string;
id: string; id: string;
} }
export interface IngredientUnit { export interface CreateIngredientFood {
name: string; name: string;
description?: string; description?: string;
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
}; };
fraction?: boolean; labelId?: string;
abbreviation?: string; }
useAbbreviation?: boolean; 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; id: string;
label?: MultiPurposeLabelSummary;
recipeReferences?: ShoppingListItemRecipeRefOut[];
createdAt?: string; createdAt?: string;
updateAt?: string; updateAt?: string;
} }
@ -358,12 +398,16 @@ export interface ShoppingListItemRecipeRefUpdate {
shoppingListItemId: string; shoppingListItemId: string;
} }
export interface ShoppingListItemUpdate { export interface ShoppingListItemUpdate {
quantity?: number;
unit?: IngredientUnit | CreateIngredientUnit;
food?: IngredientFood | CreateIngredientFood;
note?: string;
isFood?: boolean;
disableAmount?: boolean;
display?: string;
shoppingListId: string; shoppingListId: string;
checked?: boolean; checked?: boolean;
position?: number; position?: number;
isFood?: boolean;
note?: string;
quantity?: number;
foodId?: string; foodId?: string;
labelId?: string; labelId?: string;
unitId?: 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 * Only used for bulk update operations where the shopping list item id isn't already supplied
*/ */
export interface ShoppingListItemUpdateBulk { export interface ShoppingListItemUpdateBulk {
quantity?: number;
unit?: IngredientUnit | CreateIngredientUnit;
food?: IngredientFood | CreateIngredientFood;
note?: string;
isFood?: boolean;
disableAmount?: boolean;
display?: string;
shoppingListId: string; shoppingListId: string;
checked?: boolean; checked?: boolean;
position?: number; position?: number;
isFood?: boolean;
note?: string;
quantity?: number;
foodId?: string; foodId?: string;
labelId?: string; labelId?: string;
unitId?: string; unitId?: string;
@ -512,3 +560,12 @@ export interface ShoppingListUpdate {
id: string; id: string;
listItems?: ShoppingListItemOut[]; 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; ingredient: RecipeIngredient;
} }
export interface RecipeIngredient { export interface RecipeIngredient {
title?: string; quantity?: number;
note?: string;
unit?: IngredientUnit | CreateIngredientUnit; unit?: IngredientUnit | CreateIngredientUnit;
food?: IngredientFood | CreateIngredientFood; food?: IngredientFood | CreateIngredientFood;
note?: string;
isFood?: boolean;
disableAmount?: boolean; disableAmount?: boolean;
quantity?: number; display?: string;
title?: string;
originalText?: string; originalText?: string;
referenceId?: string; referenceId?: string;
} }
@ -303,6 +305,15 @@ export interface RecipeCommentUpdate {
export interface RecipeDuplicate { export interface RecipeDuplicate {
name?: string; name?: string;
} }
export interface RecipeIngredientBase {
quantity?: number;
unit?: IngredientUnit | CreateIngredientUnit;
food?: IngredientFood | CreateIngredientFood;
note?: string;
isFood?: boolean;
disableAmount?: boolean;
display?: string;
}
export interface RecipeLastMade { export interface RecipeLastMade {
timestamp: string; timestamp: string;
} }

View File

@ -80,6 +80,7 @@ const MIGRATIONS = {
copymethat: "copymethat", copymethat: "copymethat",
paprika: "paprika", paprika: "paprika",
mealie: "mealie_alpha", mealie: "mealie_alpha",
tandoor: "tandoor",
}; };
export default defineComponent({ export default defineComponent({
@ -118,6 +119,10 @@ export default defineComponent({
text: i18n.tc("migration.mealie-pre-v1.title"), text: i18n.tc("migration.mealie-pre-v1.title"),
value: MIGRATIONS.mealie, value: MIGRATIONS.mealie,
}, },
{
text: i18n.tc("migration.tandoor.title"),
value: MIGRATIONS.tandoor,
},
]; ];
const _content = { 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) { function setFileObject(fileObject: File) {

View File

@ -16,6 +16,7 @@ from mealie.services.migrations import (
MealieAlphaMigrator, MealieAlphaMigrator,
NextcloudMigrator, NextcloudMigrator,
PaprikaMigrator, PaprikaMigrator,
TandoorMigrator,
) )
router = UserAPIRouter(prefix="/groups/migrations", tags=["Group: Migrations"]) router = UserAPIRouter(prefix="/groups/migrations", tags=["Group: Migrations"])
@ -50,6 +51,7 @@ class GroupMigrationController(BaseUserController):
SupportedMigrations.mealie_alpha: MealieAlphaMigrator, SupportedMigrations.mealie_alpha: MealieAlphaMigrator,
SupportedMigrations.nextcloud: NextcloudMigrator, SupportedMigrations.nextcloud: NextcloudMigrator,
SupportedMigrations.paprika: PaprikaMigrator, SupportedMigrations.paprika: PaprikaMigrator,
SupportedMigrations.tandoor: TandoorMigrator,
} }
constructor = table.get(migration_type, None) constructor = table.get(migration_type, None)

View File

@ -14,11 +14,7 @@ from .group_events import (
from .group_exports import GroupDataExport from .group_exports import GroupDataExport
from .group_migration import DataMigrationCreate, SupportedMigrations from .group_migration import DataMigrationCreate, SupportedMigrations
from .group_permissions import SetPermissions from .group_permissions import SetPermissions
from .group_preferences import ( from .group_preferences import CreateGroupPreferences, ReadGroupPreferences, UpdateGroupPreferences
CreateGroupPreferences,
ReadGroupPreferences,
UpdateGroupPreferences,
)
from .group_seeder import SeederConfig from .group_seeder import SeederConfig
from .group_shopping_list import ( from .group_shopping_list import (
ShoppingListAddRecipeParams, ShoppingListAddRecipeParams,
@ -26,6 +22,7 @@ from .group_shopping_list import (
ShoppingListItemBase, ShoppingListItemBase,
ShoppingListItemCreate, ShoppingListItemCreate,
ShoppingListItemOut, ShoppingListItemOut,
ShoppingListItemPagination,
ShoppingListItemRecipeRefCreate, ShoppingListItemRecipeRefCreate,
ShoppingListItemRecipeRefOut, ShoppingListItemRecipeRefOut,
ShoppingListItemRecipeRefUpdate, ShoppingListItemRecipeRefUpdate,
@ -44,31 +41,11 @@ from .group_shopping_list import (
ShoppingListUpdate, ShoppingListUpdate,
) )
from .group_statistics import GroupStatistics, GroupStorage from .group_statistics import GroupStatistics, GroupStorage
from .invite_token import ( from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken
CreateInviteToken, from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType
EmailInitationResponse,
EmailInvitation,
ReadInviteToken,
SaveInviteToken,
)
from .webhook import (
CreateWebhook,
ReadWebhook,
SaveWebhook,
WebhookPagination,
WebhookType,
)
__all__ = [ __all__ = [
"CreateGroupPreferences", "GroupAdminUpdate",
"ReadGroupPreferences",
"UpdateGroupPreferences",
"GroupDataExport",
"CreateWebhook",
"ReadWebhook",
"SaveWebhook",
"WebhookPagination",
"WebhookType",
"GroupEventNotifierCreate", "GroupEventNotifierCreate",
"GroupEventNotifierOptions", "GroupEventNotifierOptions",
"GroupEventNotifierOptionsOut", "GroupEventNotifierOptionsOut",
@ -78,14 +55,20 @@ __all__ = [
"GroupEventNotifierSave", "GroupEventNotifierSave",
"GroupEventNotifierUpdate", "GroupEventNotifierUpdate",
"GroupEventPagination", "GroupEventPagination",
"GroupDataExport",
"DataMigrationCreate", "DataMigrationCreate",
"SupportedMigrations", "SupportedMigrations",
"SetPermissions",
"CreateGroupPreferences",
"ReadGroupPreferences",
"UpdateGroupPreferences",
"SeederConfig", "SeederConfig",
"ShoppingListAddRecipeParams", "ShoppingListAddRecipeParams",
"ShoppingListCreate", "ShoppingListCreate",
"ShoppingListItemBase", "ShoppingListItemBase",
"ShoppingListItemCreate", "ShoppingListItemCreate",
"ShoppingListItemOut", "ShoppingListItemOut",
"ShoppingListItemPagination",
"ShoppingListItemRecipeRefCreate", "ShoppingListItemRecipeRefCreate",
"ShoppingListItemRecipeRefOut", "ShoppingListItemRecipeRefOut",
"ShoppingListItemRecipeRefUpdate", "ShoppingListItemRecipeRefUpdate",
@ -102,8 +85,6 @@ __all__ = [
"ShoppingListSave", "ShoppingListSave",
"ShoppingListSummary", "ShoppingListSummary",
"ShoppingListUpdate", "ShoppingListUpdate",
"GroupAdminUpdate",
"SetPermissions",
"GroupStatistics", "GroupStatistics",
"GroupStorage", "GroupStorage",
"CreateInviteToken", "CreateInviteToken",
@ -111,4 +92,9 @@ __all__ = [
"EmailInvitation", "EmailInvitation",
"ReadInviteToken", "ReadInviteToken",
"SaveInviteToken", "SaveInviteToken",
"CreateWebhook",
"ReadWebhook",
"SaveWebhook",
"WebhookPagination",
"WebhookType",
] ]

View File

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

View File

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from fractions import Fraction
from pydantic import UUID4, validator from pydantic import UUID4, validator
from sqlalchemy.orm import joinedload, selectinload 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.labels.multi_purpose_label import MultiPurposeLabelSummary
from mealie.schema.recipe.recipe import RecipeSummary from mealie.schema.recipe.recipe import RecipeSummary
from mealie.schema.recipe.recipe_ingredient import ( from mealie.schema.recipe.recipe_ingredient import (
INGREDIENT_QTY_PRECISION,
MAX_INGREDIENT_DENOMINATOR,
IngredientFood, IngredientFood,
IngredientUnit, IngredientUnit,
RecipeIngredient, RecipeIngredient,
RecipeIngredientBase,
) )
from mealie.schema.response.pagination import PaginationBase 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): class ShoppingListItemRecipeRefCreate(MealieModel):
recipe_id: UUID4 recipe_id: UUID4
@ -63,20 +50,18 @@ class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRefUpdate):
orm_mode = True orm_mode = True
class ShoppingListItemBase(MealieModel): class ShoppingListItemBase(RecipeIngredientBase):
shopping_list_id: UUID4 shopping_list_id: UUID4
checked: bool = False checked: bool = False
position: int = 0 position: int = 0
is_food: bool = False
note: str | None = ""
quantity: float = 1 quantity: float = 1
food_id: UUID4 | None = None food_id: UUID4 | None = None
label_id: UUID4 | None = None label_id: UUID4 | None = None
unit_id: UUID4 | None = None unit_id: UUID4 | None = None
is_food: bool = False
extras: dict | None = {} extras: dict | None = {}
@ -96,12 +81,6 @@ class ShoppingListItemUpdateBulk(ShoppingListItemUpdate):
class ShoppingListItemOut(ShoppingListItemBase): class ShoppingListItemOut(ShoppingListItemBase):
id: UUID4 id: UUID4
display: str = ""
"""
How the ingredient should be displayed
Automatically calculated after the object is created
"""
food: IngredientFood | None food: IngredientFood | None
label: MultiPurposeLabelSummary | None label: MultiPurposeLabelSummary | None
@ -120,63 +99,6 @@ class ShoppingListItemOut(ShoppingListItemBase):
self.label = self.food.label self.label = self.food.label
self.label_id = self.label.id 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: class Config:
orm_mode = True orm_mode = True
getter_dict = ExtrasGetterDict getter_dict = ExtrasGetterDict

View File

@ -6,6 +6,7 @@ from .recipe import (
Recipe, Recipe,
RecipeCategory, RecipeCategory,
RecipeCategoryPagination, RecipeCategoryPagination,
RecipeLastMade,
RecipePagination, RecipePagination,
RecipeSummary, RecipeSummary,
RecipeTag, RecipeTag,
@ -58,6 +59,7 @@ from .recipe_ingredient import (
MergeUnit, MergeUnit,
ParsedIngredient, ParsedIngredient,
RecipeIngredient, RecipeIngredient,
RecipeIngredientBase,
RegisteredParser, RegisteredParser,
SaveIngredientFood, SaveIngredientFood,
SaveIngredientUnit, SaveIngredientUnit,
@ -81,28 +83,27 @@ from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, Re
from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
__all__ = [ __all__ = [
"RecipeToolCreate", "CreateRecipe",
"RecipeToolOut", "CreateRecipeBulk",
"RecipeToolResponse", "CreateRecipeByUrlBulk",
"RecipeToolSave", "Recipe",
"RecipeTimelineEventCreate", "RecipeCategory",
"RecipeTimelineEventIn", "RecipeCategoryPagination",
"RecipeTimelineEventOut", "RecipeLastMade",
"RecipeTimelineEventPagination", "RecipePagination",
"RecipeTimelineEventUpdate", "RecipeSummary",
"TimelineEventType", "RecipeTag",
"RecipeTagPagination",
"RecipeTool",
"RecipeToolPagination",
"RecipeAsset", "RecipeAsset",
"RecipeSettings", "AssignCategories",
"RecipeShareToken", "AssignSettings",
"RecipeShareTokenCreate", "AssignTags",
"RecipeShareTokenSave", "DeleteRecipes",
"RecipeShareTokenSummary", "ExportBase",
"RecipeDuplicate", "ExportRecipes",
"RecipeSlug", "ExportTypes",
"RecipeZipTokenResponse",
"SlugResponse",
"UpdateImageResponse",
"RecipeNote",
"CategoryBase", "CategoryBase",
"CategoryIn", "CategoryIn",
"CategoryOut", "CategoryOut",
@ -119,17 +120,7 @@ __all__ = [
"RecipeCommentSave", "RecipeCommentSave",
"RecipeCommentUpdate", "RecipeCommentUpdate",
"UserBase", "UserBase",
"AssignCategories",
"AssignSettings",
"AssignTags",
"DeleteRecipes",
"ExportBase",
"ExportRecipes",
"ExportTypes",
"IngredientReferences",
"RecipeStep",
"RecipeImageTypes", "RecipeImageTypes",
"Nutrition",
"CreateIngredientFood", "CreateIngredientFood",
"CreateIngredientUnit", "CreateIngredientUnit",
"IngredientConfidence", "IngredientConfidence",
@ -143,22 +134,35 @@ __all__ = [
"MergeUnit", "MergeUnit",
"ParsedIngredient", "ParsedIngredient",
"RecipeIngredient", "RecipeIngredient",
"RecipeIngredientBase",
"RegisteredParser", "RegisteredParser",
"SaveIngredientFood", "SaveIngredientFood",
"SaveIngredientUnit", "SaveIngredientUnit",
"UnitFoodBase", "UnitFoodBase",
"CreateRecipe", "RecipeNote",
"CreateRecipeBulk", "Nutrition",
"CreateRecipeByUrlBulk",
"Recipe",
"RecipeCategory",
"RecipeCategoryPagination",
"RecipePagination",
"RecipeSummary",
"RecipeTag",
"RecipeTagPagination",
"RecipeTool",
"RecipeToolPagination",
"ScrapeRecipe", "ScrapeRecipe",
"ScrapeRecipeTest", "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 orm_mode = True
getter_dict = ExtrasGetterDict 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) @validator("slug", always=True, pre=True, allow_reuse=True)
def validate_slug(slug: str, values): # type: ignore def validate_slug(slug: str, values): # type: ignore
if not values.get("name"): if not values.get("name"):

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import datetime import datetime
import enum import enum
from fractions import Fraction
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import UUID4, Field, validator from pydantic import UUID4, Field, validator
@ -17,6 +18,17 @@ from mealie.schema.response.pagination import PaginationBase
INGREDIENT_QTY_PRECISION = 3 INGREDIENT_QTY_PRECISION = 3
MAX_INGREDIENT_DENOMINATOR = 32 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): class UnitFoodBase(MealieModel):
name: str name: str
@ -70,18 +82,119 @@ class IngredientUnit(CreateIngredientUnit):
orm_mode = True 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): class IngredientUnitPagination(PaginationBase):
items: list[IngredientUnit] items: list[IngredientUnit]
class RecipeIngredient(MealieModel): class RecipeIngredient(RecipeIngredientBase):
title: str | None 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 original_text: str | None
disable_amount: bool = True
# Ref is used as a way to distinguish between an individual ingredient on the frontend # 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 # 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 orm_mode = True
@validator("quantity", pre=True) @validator("quantity", pre=True)
@classmethod def validate_quantity(cls, value) -> NoneFloat:
def validate_quantity(cls, value, values) -> NoneFloat:
""" """
Sometimes the frontend UI will provide an empty string as a "null" value because of the default 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 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 .mealie_alpha import *
from .nextcloud import * from .nextcloud import *
from .paprika 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 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 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: case None:
return default or [] return default or []
case list(ingredients): 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] return [clean_string(ingredient) for ingredient in ingredients]
case str(ingredients): case str(ingredients):
return [clean_string(ingredient) for ingredient in ingredients.splitlines()] 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_nextcloud = CWD / "migrations/nextcloud.zip"
migrations_tandoor = CWD / "migrations/tandoor.zip"
images_test_image_1 = CWD / "images/test-image-1.jpg" images_test_image_1 = CWD / "images/test-image-1.jpg"
images_test_image_2 = CWD / "images/test-image-2.png" 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.chowdown, archive=test_data.migrations_chowdown),
MigrationTestData(typ=SupportedMigrations.copymethat, archive=test_data.migrations_copymethat), MigrationTestData(typ=SupportedMigrations.copymethat, archive=test_data.migrations_copymethat),
MigrationTestData(typ=SupportedMigrations.mealie_alpha, archive=test_data.migrations_mealie), MigrationTestData(typ=SupportedMigrations.mealie_alpha, archive=test_data.migrations_mealie),
MigrationTestData(typ=SupportedMigrations.tandoor, archive=test_data.migrations_tandoor),
] ]
test_ids = [ test_ids = [
@ -31,6 +32,7 @@ test_ids = [
"chowdown_archive", "chowdown_archive",
"copymethat_archive", "copymethat_archive",
"mealie_alpha_archive", "mealie_alpha_archive",
"tandoor_archive",
] ]