mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -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:
parent
c25b58e404
commit
0f896107f9
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user