mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-23 07:09:41 +02:00
feat: Structured Yields (#4489)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
parent
c8cd68b4f0
commit
327da02fc8
39 changed files with 1018 additions and 551 deletions
|
@ -8,7 +8,6 @@ Create Date: 2024-10-20 09:47:46.844436
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
import mealie.db.migration_types
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""add recipe yield quantity
|
||||||
|
|
||||||
|
Revision ID: b1020f328e98
|
||||||
|
Revises: 3897397b4631
|
||||||
|
Create Date: 2024-10-23 15:50:59.888793
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from mealie.db.models._model_utils.guid import GUID
|
||||||
|
from mealie.services.scraper.cleaner import clean_yield
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "b1020f328e98"
|
||||||
|
down_revision: str | None = "3897397b4631"
|
||||||
|
branch_labels: str | tuple[str, ...] | None = None
|
||||||
|
depends_on: str | tuple[str, ...] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Intermediate table definitions
|
||||||
|
class SqlAlchemyBase(orm.DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeModel(SqlAlchemyBase):
|
||||||
|
__tablename__ = "recipes"
|
||||||
|
|
||||||
|
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||||
|
recipe_yield: orm.Mapped[str | None] = orm.mapped_column(sa.String)
|
||||||
|
recipe_yield_quantity: orm.Mapped[float] = orm.mapped_column(sa.Float, index=True, default=0)
|
||||||
|
recipe_servings: orm.Mapped[float] = orm.mapped_column(sa.Float, index=True, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_recipe_yields():
|
||||||
|
bind = op.get_bind()
|
||||||
|
session = orm.Session(bind=bind)
|
||||||
|
|
||||||
|
for recipe in session.query(RecipeModel).all():
|
||||||
|
try:
|
||||||
|
recipe.recipe_servings, recipe.recipe_yield_quantity, recipe.recipe_yield = clean_yield(recipe.recipe_yield)
|
||||||
|
except Exception:
|
||||||
|
recipe.recipe_servings = 0
|
||||||
|
recipe.recipe_yield_quantity = 0
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table("recipes", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("recipe_yield_quantity", sa.Float(), nullable=False, server_default="0"))
|
||||||
|
batch_op.create_index(batch_op.f("ix_recipes_recipe_yield_quantity"), ["recipe_yield_quantity"], unique=False)
|
||||||
|
batch_op.add_column(sa.Column("recipe_servings", sa.Float(), nullable=False, server_default="0"))
|
||||||
|
batch_op.create_index(batch_op.f("ix_recipes_recipe_servings"), ["recipe_servings"], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
parse_recipe_yields()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table("recipes", schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f("ix_recipes_recipe_servings"))
|
||||||
|
batch_op.drop_column("recipe_servings")
|
||||||
|
batch_op.drop_index(batch_op.f("ix_recipes_recipe_yield_quantity"))
|
||||||
|
batch_op.drop_column("recipe_yield_quantity")
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
|
@ -63,6 +63,8 @@ interface ShowHeaders {
|
||||||
tags: boolean;
|
tags: boolean;
|
||||||
categories: boolean;
|
categories: boolean;
|
||||||
tools: boolean;
|
tools: boolean;
|
||||||
|
recipeServings: boolean;
|
||||||
|
recipeYieldQuantity: boolean;
|
||||||
recipeYield: boolean;
|
recipeYield: boolean;
|
||||||
dateAdded: boolean;
|
dateAdded: boolean;
|
||||||
}
|
}
|
||||||
|
@ -93,6 +95,8 @@ export default defineComponent({
|
||||||
owner: false,
|
owner: false,
|
||||||
tags: true,
|
tags: true,
|
||||||
categories: true,
|
categories: true,
|
||||||
|
recipeServings: true,
|
||||||
|
recipeYieldQuantity: true,
|
||||||
recipeYield: true,
|
recipeYield: true,
|
||||||
dateAdded: true,
|
dateAdded: true,
|
||||||
};
|
};
|
||||||
|
@ -127,8 +131,14 @@ export default defineComponent({
|
||||||
if (props.showHeaders.tools) {
|
if (props.showHeaders.tools) {
|
||||||
hdrs.push({ text: i18n.t("tool.tools"), value: "tools" });
|
hdrs.push({ text: i18n.t("tool.tools"), value: "tools" });
|
||||||
}
|
}
|
||||||
|
if (props.showHeaders.recipeServings) {
|
||||||
|
hdrs.push({ text: i18n.t("recipe.servings"), value: "recipeServings" });
|
||||||
|
}
|
||||||
|
if (props.showHeaders.recipeYieldQuantity) {
|
||||||
|
hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYieldQuantity" });
|
||||||
|
}
|
||||||
if (props.showHeaders.recipeYield) {
|
if (props.showHeaders.recipeYield) {
|
||||||
hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYield" });
|
hdrs.push({ text: i18n.t("recipe.yield-text"), value: "recipeYield" });
|
||||||
}
|
}
|
||||||
if (props.showHeaders.dateAdded) {
|
if (props.showHeaders.dateAdded) {
|
||||||
hdrs.push({ text: i18n.t("general.date-added"), value: "dateAdded" });
|
hdrs.push({ text: i18n.t("general.date-added"), value: "dateAdded" });
|
||||||
|
|
|
@ -86,12 +86,6 @@
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="d-flex justify-center flex-wrap">
|
|
||||||
<BaseButton :small="$vuetify.breakpoint.smAndDown" @click="madeThisDialog = true">
|
|
||||||
<template #icon> {{ $globals.icons.chefHat }} </template>
|
|
||||||
{{ $t('recipe.made-this') }}
|
|
||||||
</BaseButton>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-center flex-wrap">
|
<div class="d-flex justify-center flex-wrap">
|
||||||
<v-chip
|
<v-chip
|
||||||
label
|
label
|
||||||
|
@ -105,6 +99,12 @@
|
||||||
{{ $t('recipe.last-made-date', { date: value ? new Date(value).toLocaleDateString($i18n.locale) : $t("general.never") } ) }}
|
{{ $t('recipe.last-made-date', { date: value ? new Date(value).toLocaleDateString($i18n.locale) : $t("general.never") } ) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex justify-center flex-wrap mt-1">
|
||||||
|
<BaseButton :small="$vuetify.breakpoint.smAndDown" @click="madeThisDialog = true">
|
||||||
|
<template #icon> {{ $globals.icons.chefHat }} </template>
|
||||||
|
{{ $t('recipe.made-this') }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -125,7 +125,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
recipe: {
|
recipe: {
|
||||||
type: Object as () => Recipe,
|
type: Object as () => Recipe,
|
||||||
default: null,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
|
|
|
@ -21,10 +21,10 @@
|
||||||
a significant amount of prop management. When we move to Vue 3 and have access to some of the newer API's the plan to update this
|
a significant amount of prop management. When we move to Vue 3 and have access to some of the newer API's the plan to update this
|
||||||
data management and mutation system we're using.
|
data management and mutation system we're using.
|
||||||
-->
|
-->
|
||||||
|
<RecipePageInfoEditor v-if="isEditMode" :recipe="recipe" :landscape="landscape" />
|
||||||
<RecipePageEditorToolbar v-if="isEditForm" :recipe="recipe" />
|
<RecipePageEditorToolbar v-if="isEditForm" :recipe="recipe" />
|
||||||
<RecipePageTitleContent :recipe="recipe" :landscape="landscape" />
|
|
||||||
<RecipePageIngredientEditor v-if="isEditForm" :recipe="recipe" />
|
<RecipePageIngredientEditor v-if="isEditForm" :recipe="recipe" />
|
||||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape" />
|
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
This section contains the 2 column layout for the recipe steps and other content.
|
This section contains the 2 column layout for the recipe steps and other content.
|
||||||
|
@ -76,7 +76,7 @@
|
||||||
<v-row style="height: 100%;" no-gutters class="overflow-hidden">
|
<v-row style="height: 100%;" no-gutters class="overflow-hidden">
|
||||||
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%;">
|
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%;">
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape" />
|
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
|
||||||
</div>
|
</div>
|
||||||
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" :is-cook-mode="isCookMode" />
|
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" :is-cook-mode="isCookMode" />
|
||||||
<v-divider></v-divider>
|
<v-divider></v-divider>
|
||||||
|
@ -95,7 +95,7 @@
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
<v-sheet v-show="isCookMode && hasLinkedIngredients">
|
<v-sheet v-show="isCookMode && hasLinkedIngredients">
|
||||||
<div class="mt-2 px-2 px-md-4">
|
<div class="mt-2 px-2 px-md-4">
|
||||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape"/>
|
<RecipePageScale :recipe="recipe" :scale.sync="scale"/>
|
||||||
</div>
|
</div>
|
||||||
<RecipePageInstructions
|
<RecipePageInstructions
|
||||||
v-model="recipe.recipeInstructions"
|
v-model="recipe.recipeInstructions"
|
||||||
|
@ -154,7 +154,7 @@ import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredien
|
||||||
import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue";
|
import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue";
|
||||||
import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
|
import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
|
||||||
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
|
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
|
||||||
import RecipePageTitleContent from "./RecipePageParts/RecipePageTitleContent.vue";
|
import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue";
|
||||||
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue";
|
import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue";
|
||||||
|
@ -185,7 +185,7 @@ export default defineComponent({
|
||||||
RecipePageHeader,
|
RecipePageHeader,
|
||||||
RecipePrintContainer,
|
RecipePrintContainer,
|
||||||
RecipePageComments,
|
RecipePageComments,
|
||||||
RecipePageTitleContent,
|
RecipePageInfoEditor,
|
||||||
RecipePageEditorToolbar,
|
RecipePageEditorToolbar,
|
||||||
RecipePageIngredientEditor,
|
RecipePageIngredientEditor,
|
||||||
RecipePageOrganizers,
|
RecipePageOrganizers,
|
||||||
|
@ -195,7 +195,7 @@ export default defineComponent({
|
||||||
RecipeNotes,
|
RecipeNotes,
|
||||||
RecipePageInstructions,
|
RecipePageInstructions,
|
||||||
RecipePageFooter,
|
RecipePageFooter,
|
||||||
RecipeIngredients
|
RecipeIngredients,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
recipe: {
|
recipe: {
|
||||||
|
|
|
@ -1,46 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="d-flex justify-end flex-wrap align-stretch">
|
<RecipePageInfoCard :recipe="recipe" :recipe-scale="recipeScale" :landscape="landscape" />
|
||||||
<v-card v-if="!landscape" width="50%" flat class="d-flex flex-column justify-center align-center">
|
<v-divider />
|
||||||
<v-card-text>
|
|
||||||
<v-card-title class="headline pa-0 flex-column align-center">
|
|
||||||
{{ recipe.name }}
|
|
||||||
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
|
|
||||||
</v-card-title>
|
|
||||||
<v-divider class="my-2"></v-divider>
|
|
||||||
<SafeMarkdown :source="recipe.description" />
|
|
||||||
<v-divider></v-divider>
|
|
||||||
<div v-if="isOwnGroup" class="d-flex justify-center mt-5">
|
|
||||||
<RecipeLastMade
|
|
||||||
v-model="recipe.lastMade"
|
|
||||||
:recipe="recipe"
|
|
||||||
class="d-flex justify-center flex-wrap"
|
|
||||||
:class="true ? undefined : 'force-bottom'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-center mt-5">
|
|
||||||
<RecipeTimeCard
|
|
||||||
class="d-flex justify-center flex-wrap"
|
|
||||||
:class="true ? undefined : 'force-bottom'"
|
|
||||||
:prep-time="recipe.prepTime"
|
|
||||||
:total-time="recipe.totalTime"
|
|
||||||
:perform-time="recipe.performTime"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
<v-img
|
|
||||||
:key="imageKey"
|
|
||||||
:max-width="landscape ? null : '50%'"
|
|
||||||
min-height="50"
|
|
||||||
:height="hideImage ? undefined : imageHeight"
|
|
||||||
:src="recipeImageUrl"
|
|
||||||
class="d-print-none"
|
|
||||||
@error="hideImage = true"
|
|
||||||
>
|
|
||||||
</v-img>
|
|
||||||
</div>
|
|
||||||
<v-divider></v-divider>
|
|
||||||
<RecipeActionMenu
|
<RecipeActionMenu
|
||||||
:recipe="recipe"
|
:recipe="recipe"
|
||||||
:slug="recipe.slug"
|
:slug="recipe.slug"
|
||||||
|
@ -65,10 +26,8 @@
|
||||||
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
|
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { useRecipePermissions } from "~/composables/recipes";
|
import { useRecipePermissions } from "~/composables/recipes";
|
||||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
|
||||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
|
||||||
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
||||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
|
||||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import { Recipe } from "~/lib/api/types/recipe";
|
||||||
|
@ -76,10 +35,8 @@ import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
|
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeTimeCard,
|
RecipePageInfoCard,
|
||||||
RecipeActionMenu,
|
RecipeActionMenu,
|
||||||
RecipeRating,
|
|
||||||
RecipeLastMade,
|
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
recipe: {
|
recipe: {
|
||||||
|
|
|
@ -0,0 +1,101 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex justify-end flex-wrap align-stretch">
|
||||||
|
<RecipePageInfoCardImage v-if="landscape" :recipe="recipe" />
|
||||||
|
<v-card
|
||||||
|
:width="landscape ? '100%' : '50%'"
|
||||||
|
flat
|
||||||
|
class="d-flex flex-column justify-center align-center"
|
||||||
|
>
|
||||||
|
<v-card-text>
|
||||||
|
<v-card-title class="headline pa-0 flex-column align-center">
|
||||||
|
{{ recipe.name }}
|
||||||
|
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider class="my-2" />
|
||||||
|
<SafeMarkdown :source="recipe.description" />
|
||||||
|
<v-divider />
|
||||||
|
<v-container class="d-flex flex-row flex-wrap justify-center align-center">
|
||||||
|
<div class="mx-5">
|
||||||
|
<v-row no-gutters class="mb-1">
|
||||||
|
<v-col v-if="recipe.recipeYieldQuantity || recipe.recipeYield" cols="12" class="d-flex flex-wrap justify-center">
|
||||||
|
<RecipeYield
|
||||||
|
:yield-quantity="recipe.recipeYieldQuantity"
|
||||||
|
:yield="recipe.recipeYield"
|
||||||
|
:scale="recipeScale"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row no-gutters>
|
||||||
|
<v-col cols="12" class="d-flex flex-wrap justify-center">
|
||||||
|
<RecipeLastMade
|
||||||
|
v-if="isOwnGroup"
|
||||||
|
:value="recipe.lastMade"
|
||||||
|
:recipe="recipe"
|
||||||
|
:class="true ? undefined : 'force-bottom'"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
<div class="mx-5">
|
||||||
|
<RecipeTimeCard
|
||||||
|
stacked
|
||||||
|
container-class="d-flex flex-wrap justify-center"
|
||||||
|
:prep-time="recipe.prepTime"
|
||||||
|
:total-time="recipe.totalTime"
|
||||||
|
:perform-time="recipe.performTime"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-container>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
<RecipePageInfoCardImage v-if="!landscape" :recipe="recipe" max-width="50%" class="my-auto" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||||
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||||
|
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
||||||
|
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||||
|
import RecipeYield from "~/components/Domain/Recipe/RecipeYield.vue";
|
||||||
|
import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue";
|
||||||
|
import { Recipe } from "~/lib/api/types/recipe";
|
||||||
|
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
|
export default defineComponent({
|
||||||
|
components: {
|
||||||
|
RecipeRating,
|
||||||
|
RecipeLastMade,
|
||||||
|
RecipeTimeCard,
|
||||||
|
RecipeYield,
|
||||||
|
RecipePageInfoCardImage,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
recipe: {
|
||||||
|
type: Object as () => NoUndefinedField<Recipe>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
recipeScale: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
landscape: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
const { $vuetify } = useContext();
|
||||||
|
const useMobile = computed(() => $vuetify.breakpoint.smAndDown);
|
||||||
|
|
||||||
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOwnGroup,
|
||||||
|
useMobile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -0,0 +1,69 @@
|
||||||
|
<template>
|
||||||
|
<v-img
|
||||||
|
:key="imageKey"
|
||||||
|
:max-width="maxWidth"
|
||||||
|
min-height="50"
|
||||||
|
:height="hideImage ? undefined : imageHeight"
|
||||||
|
:src="recipeImageUrl"
|
||||||
|
class="d-print-none"
|
||||||
|
@error="hideImage = true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
|
||||||
|
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||||
|
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
|
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||||
|
import { Recipe } from "~/lib/api/types/recipe";
|
||||||
|
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
recipe: {
|
||||||
|
type: Object as () => NoUndefinedField<Recipe>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
maxWidth: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { $vuetify } = useContext();
|
||||||
|
const { recipeImage } = useStaticRoutes();
|
||||||
|
const { imageKey } = usePageState(props.recipe.slug);
|
||||||
|
const { user } = usePageUser();
|
||||||
|
|
||||||
|
const recipeHousehold = ref<HouseholdSummary>();
|
||||||
|
if (user) {
|
||||||
|
const userApi = useUserApi();
|
||||||
|
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
|
||||||
|
recipeHousehold.value = data || undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideImage = ref(false);
|
||||||
|
const imageHeight = computed(() => {
|
||||||
|
return $vuetify.breakpoint.xs ? "200" : "400";
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipeImageUrl = computed(() => {
|
||||||
|
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => recipeImageUrl.value,
|
||||||
|
() => {
|
||||||
|
hideImage.value = false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
recipeImageUrl,
|
||||||
|
imageKey,
|
||||||
|
hideImage,
|
||||||
|
imageHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -0,0 +1,107 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-text-field
|
||||||
|
v-model="recipe.name"
|
||||||
|
class="my-3"
|
||||||
|
:label="$t('recipe.recipe-name')"
|
||||||
|
:rules="[validators.required]"
|
||||||
|
/>
|
||||||
|
<v-container class="ma-0 pa-0">
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="3">
|
||||||
|
<v-text-field
|
||||||
|
v-model="recipeServings"
|
||||||
|
type="number"
|
||||||
|
:min="0"
|
||||||
|
hide-spin-buttons
|
||||||
|
dense
|
||||||
|
:label="$t('recipe.servings')"
|
||||||
|
@input="validateInput($event, 'recipeServings')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="3">
|
||||||
|
<v-text-field
|
||||||
|
v-model="recipeYieldQuantity"
|
||||||
|
type="number"
|
||||||
|
:min="0"
|
||||||
|
hide-spin-buttons
|
||||||
|
dense
|
||||||
|
:label="$t('recipe.yield')"
|
||||||
|
@input="validateInput($event, 'recipeYieldQuantity')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="recipe.recipeYield"
|
||||||
|
dense
|
||||||
|
:label="$t('recipe.yield-text')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 1rem">
|
||||||
|
<v-text-field v-model="recipe.totalTime" :label="$t('recipe.total-time')" />
|
||||||
|
<v-text-field v-model="recipe.prepTime" :label="$t('recipe.prep-time')" />
|
||||||
|
<v-text-field v-model="recipe.performTime" :label="$t('recipe.perform-time')" />
|
||||||
|
</div>
|
||||||
|
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||||
|
import { validators } from "~/composables/use-validators";
|
||||||
|
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
|
import { Recipe } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
recipe: {
|
||||||
|
type: Object as () => NoUndefinedField<Recipe>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const recipeServings = computed<number>({
|
||||||
|
get() {
|
||||||
|
return props.recipe.recipeServings;
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
validateInput(val.toString(), "recipeServings");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipeYieldQuantity = computed<number>({
|
||||||
|
get() {
|
||||||
|
return props.recipe.recipeYieldQuantity;
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
validateInput(val.toString(), "recipeYieldQuantity");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
|
||||||
|
if (!value) {
|
||||||
|
props.recipe[property] = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = parseFloat(value.replace(/[^0-9.]/g, ""));
|
||||||
|
if (isNaN(number) || number <= 0) {
|
||||||
|
props.recipe[property] = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
props.recipe[property] = number;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
validators,
|
||||||
|
recipeServings,
|
||||||
|
recipeYieldQuantity,
|
||||||
|
validateInput,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -5,50 +5,32 @@
|
||||||
<RecipeScaleEditButton
|
<RecipeScaleEditButton
|
||||||
v-model.number="scaleValue"
|
v-model.number="scaleValue"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
:recipe-yield="recipe.recipeYield"
|
:recipe-servings="recipeServings"
|
||||||
:scaled-yield="scaledYield"
|
|
||||||
:basic-yield-num="basicYieldNum"
|
|
||||||
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
|
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
|
||||||
v-on="on"
|
v-on="on"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<span> {{ $t("recipe.edit-scale") }} </span>
|
<span> {{ $t("recipe.edit-scale") }} </span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<v-spacer></v-spacer>
|
|
||||||
|
|
||||||
<RecipeRating
|
|
||||||
v-if="landscape && $vuetify.breakpoint.smAndUp"
|
|
||||||
:key="recipe.slug"
|
|
||||||
v-model="recipe.rating"
|
|
||||||
:recipe-id="recipe.id"
|
|
||||||
:slug="recipe.slug"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, ref } from "@nuxtjs/composition-api";
|
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||||
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
|
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
|
||||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||||
import { useExtractRecipeYield, findMatch } from "~/composables/recipe-page/use-extract-recipe-yield";
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeScaleEditButton,
|
RecipeScaleEditButton,
|
||||||
RecipeRating,
|
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
recipe: {
|
recipe: {
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
type: Object as () => NoUndefinedField<Recipe>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
landscape: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
scale: {
|
scale: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 1,
|
default: 1,
|
||||||
|
@ -57,6 +39,10 @@ export default defineComponent({
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const { isEditMode } = usePageState(props.recipe.slug);
|
const { isEditMode } = usePageState(props.recipe.slug);
|
||||||
|
|
||||||
|
const recipeServings = computed<number>(() => {
|
||||||
|
return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
|
||||||
|
});
|
||||||
|
|
||||||
const scaleValue = computed<number>({
|
const scaleValue = computed<number>({
|
||||||
get() {
|
get() {
|
||||||
return props.scale;
|
return props.scale;
|
||||||
|
@ -66,17 +52,9 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const scaledYield = computed(() => {
|
|
||||||
return useExtractRecipeYield(props.recipe.recipeYield, scaleValue.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
const match = findMatch(props.recipe.recipeYield);
|
|
||||||
const basicYieldNum = ref<number |null>(match ? match[1] : null);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
recipeServings,
|
||||||
scaleValue,
|
scaleValue,
|
||||||
scaledYield,
|
|
||||||
basicYieldNum,
|
|
||||||
isEditMode,
|
isEditMode,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,92 +0,0 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<template v-if="!isEditMode && landscape">
|
|
||||||
<v-card-title class="px-0 py-2 ma-0 headline">
|
|
||||||
{{ recipe.name }}
|
|
||||||
</v-card-title>
|
|
||||||
<SafeMarkdown :source="recipe.description" />
|
|
||||||
<div v-if="isOwnGroup" class="pb-2 d-flex justify-center flex-wrap">
|
|
||||||
<RecipeLastMade
|
|
||||||
v-model="recipe.lastMade"
|
|
||||||
:recipe="recipe"
|
|
||||||
class="d-flex justify-center flex-wrap"
|
|
||||||
:class="true ? undefined : 'force-bottom'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pb-2 d-flex justify-center flex-wrap">
|
|
||||||
<RecipeTimeCard
|
|
||||||
class="d-flex justify-center flex-wrap"
|
|
||||||
:prep-time="recipe.prepTime"
|
|
||||||
:total-time="recipe.totalTime"
|
|
||||||
:perform-time="recipe.performTime"
|
|
||||||
/>
|
|
||||||
<RecipeRating
|
|
||||||
v-if="$vuetify.breakpoint.smAndDown"
|
|
||||||
:key="recipe.slug"
|
|
||||||
v-model="recipe.rating"
|
|
||||||
:recipe-id="recipe.id"
|
|
||||||
:slug="recipe.slug"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<v-divider></v-divider>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="isEditMode">
|
|
||||||
<v-text-field
|
|
||||||
v-model="recipe.name"
|
|
||||||
class="my-3"
|
|
||||||
:label="$t('recipe.recipe-name')"
|
|
||||||
:rules="[validators.required]"
|
|
||||||
/>
|
|
||||||
<v-text-field v-model="recipe.recipeYield" dense :label="$t('recipe.servings')" />
|
|
||||||
<div class="d-flex flex-wrap" style="gap: 1rem">
|
|
||||||
<v-text-field v-model="recipe.totalTime" :label="$t('recipe.total-time')" />
|
|
||||||
<v-text-field v-model="recipe.prepTime" :label="$t('recipe.prep-time')" />
|
|
||||||
<v-text-field v-model="recipe.performTime" :label="$t('recipe.perform-time')" />
|
|
||||||
</div>
|
|
||||||
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
|
||||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
|
||||||
import { validators } from "~/composables/use-validators";
|
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
|
||||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
|
||||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
|
||||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
components: {
|
|
||||||
RecipeRating,
|
|
||||||
RecipeTimeCard,
|
|
||||||
RecipeLastMade,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
recipe: {
|
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
landscape: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const { user } = usePageUser();
|
|
||||||
const { imageKey, isEditMode } = usePageState(props.recipe.slug);
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
imageKey,
|
|
||||||
validators,
|
|
||||||
isEditMode,
|
|
||||||
isOwnGroup,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
|
@ -18,7 +18,24 @@
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ recipe.name }}
|
{{ recipe.name }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<RecipeTimeCard :prep-time="recipe.prepTime" :total-time="recipe.totalTime" :perform-time="recipe.performTime" color="white" />
|
<div v-if="recipeYield" class="d-flex justify-space-between align-center px-4 pb-2">
|
||||||
|
<v-chip
|
||||||
|
:small="$vuetify.breakpoint.smAndDown"
|
||||||
|
label
|
||||||
|
>
|
||||||
|
<v-icon left>
|
||||||
|
{{ $globals.icons.potSteam }}
|
||||||
|
</v-icon>
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<span v-html="recipeYield"></span>
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
<RecipeTimeCard
|
||||||
|
:prep-time="recipe.prepTime"
|
||||||
|
:total-time="recipe.totalTime"
|
||||||
|
:perform-time="recipe.performTime"
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
<v-card-text v-if="preferences.showDescription" class="px-0">
|
<v-card-text v-if="preferences.showDescription" class="px-0">
|
||||||
<SafeMarkdown :source="recipe.description" />
|
<SafeMarkdown :source="recipe.description" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
@ -30,9 +47,6 @@
|
||||||
<!-- Ingredients -->
|
<!-- Ingredients -->
|
||||||
<section>
|
<section>
|
||||||
<v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title>
|
<v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title>
|
||||||
<div class="font-italic px-0 py-0">
|
|
||||||
<SafeMarkdown :source="recipe.recipeYield" />
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
||||||
:key="`ingredient-section-${sectionIndex}`"
|
:key="`ingredient-section-${sectionIndex}`"
|
||||||
|
@ -111,7 +125,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||||
import { useStaticRoutes } from "~/composables/api";
|
import { useStaticRoutes } from "~/composables/api";
|
||||||
import { Recipe, RecipeIngredient, RecipeStep} from "~/lib/api/types/recipe";
|
import { Recipe, RecipeIngredient, RecipeStep} from "~/lib/api/types/recipe";
|
||||||
|
@ -119,6 +134,7 @@ import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||||
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
|
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
|
||||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||||
|
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||||
|
|
||||||
|
|
||||||
type IngredientSection = {
|
type IngredientSection = {
|
||||||
|
@ -151,13 +167,39 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
|
const { i18n } = useContext();
|
||||||
const preferences = useUserPrintPreferences();
|
const preferences = useUserPrintPreferences();
|
||||||
const { recipeImage } = useStaticRoutes();
|
const { recipeImage } = useStaticRoutes();
|
||||||
const { imageKey } = usePageState(props.recipe.slug);
|
const { imageKey } = usePageState(props.recipe.slug);
|
||||||
const {labels} = useNutritionLabels();
|
const {labels} = useNutritionLabels();
|
||||||
|
|
||||||
|
function sanitizeHTML(rawHtml: string) {
|
||||||
|
return DOMPurify.sanitize(rawHtml, {
|
||||||
|
USE_PROFILES: { html: true },
|
||||||
|
ALLOWED_TAGS: ["strong", "sup"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const servingsDisplay = computed(() => {
|
||||||
|
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
|
||||||
|
return scaledAmountDisplay ? i18n.t("recipe.yields-amount-with-text", {
|
||||||
|
amount: scaledAmountDisplay,
|
||||||
|
text: props.recipe.recipeYield,
|
||||||
|
}) as string : "";
|
||||||
|
})
|
||||||
|
|
||||||
|
const yieldDisplay = computed(() => {
|
||||||
|
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
|
||||||
|
return scaledAmountDisplay ? i18n.t("recipe.serves-amount", { amount: scaledAmountDisplay }) as string : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipeYield = computed(() => {
|
||||||
|
if (servingsDisplay.value && yieldDisplay.value) {
|
||||||
|
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
|
||||||
|
} else {
|
||||||
|
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const recipeImageUrl = computed(() => {
|
const recipeImageUrl = computed(() => {
|
||||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||||
|
@ -258,6 +300,7 @@ export default defineComponent({
|
||||||
parseIngredientText,
|
parseIngredientText,
|
||||||
preferences,
|
preferences,
|
||||||
recipeImageUrl,
|
recipeImageUrl,
|
||||||
|
recipeYield,
|
||||||
ingredientSections,
|
ingredientSections,
|
||||||
instructionSections,
|
instructionSections,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,16 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div v-if="yieldDisplay">
|
||||||
<div class="text-center d-flex align-center">
|
<div class="text-center d-flex align-center">
|
||||||
<div>
|
<div>
|
||||||
<v-menu v-model="menu" :disabled="!editScale" offset-y top nudge-top="6" :close-on-content-click="false">
|
<v-menu v-model="menu" :disabled="!canEditScale" offset-y top nudge-top="6" :close-on-content-click="false">
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ on, attrs }">
|
||||||
<v-card class="pa-1 px-2" dark color="secondary darken-1" small v-bind="attrs" v-on="on">
|
<v-card class="pa-1 px-2" dark color="secondary darken-1" small v-bind="attrs" v-on="on">
|
||||||
<span v-if="!recipeYield"> x {{ scale }} </span>
|
<v-icon small class="mr-2">{{ $globals.icons.edit }}</v-icon>
|
||||||
<div v-else-if="!numberParsed && recipeYield">
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<span v-if="numerator === 1"> {{ recipeYield }} </span>
|
<span v-html="yieldDisplay"></span>
|
||||||
<span v-else> {{ numerator }}x {{ scaledYield }} </span>
|
|
||||||
</div>
|
|
||||||
<span v-else> {{ scaledYield }} </span>
|
|
||||||
|
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
@ -20,7 +17,7 @@
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text class="mt-n5">
|
<v-card-text class="mt-n5">
|
||||||
<div class="mt-4 d-flex align-center">
|
<div class="mt-4 d-flex align-center">
|
||||||
<v-text-field v-model="numerator" type="number" :min="0" hide-spin-buttons />
|
<v-text-field v-model="yieldQuantityEditorValue" type="number" :min="0" hide-spin-buttons @input="recalculateScale(yieldQuantityEditorValue)" />
|
||||||
<v-tooltip right color="secondary darken-1">
|
<v-tooltip right color="secondary darken-1">
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ on, attrs }">
|
||||||
<v-btn v-bind="attrs" icon class="mx-1" small v-on="on" @click="scale = 1">
|
<v-btn v-bind="attrs" icon class="mx-1" small v-on="on" @click="scale = 1">
|
||||||
|
@ -37,7 +34,7 @@
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</div>
|
</div>
|
||||||
<BaseButtonGroup
|
<BaseButtonGroup
|
||||||
v-if="editScale"
|
v-if="canEditScale"
|
||||||
class="pl-2"
|
class="pl-2"
|
||||||
:large="false"
|
:large="false"
|
||||||
:buttons="[
|
:buttons="[
|
||||||
|
@ -53,41 +50,36 @@
|
||||||
event: 'increment',
|
event: 'increment',
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
@decrement="numerator--"
|
@decrement="recalculateScale(yieldQuantity - 1)"
|
||||||
@increment="numerator++"
|
@increment="recalculateScale(yieldQuantity + 1)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, ref, computed, watch } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
|
||||||
|
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
recipeYield: {
|
value: {
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
scaledYield: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
basicYieldNum: {
|
|
||||||
type: Number,
|
type: Number,
|
||||||
default: null,
|
required: true,
|
||||||
|
},
|
||||||
|
recipeServings: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
},
|
},
|
||||||
editScale: {
|
editScale: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
value: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
|
const { i18n } = useContext();
|
||||||
const menu = ref<boolean>(false);
|
const menu = ref<boolean>(false);
|
||||||
|
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
|
||||||
|
|
||||||
const scale = computed({
|
const scale = computed({
|
||||||
get: () => props.value,
|
get: () => props.value,
|
||||||
|
@ -97,24 +89,54 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const numerator = ref<number>(props.basicYieldNum != null ? parseFloat(props.basicYieldNum.toFixed(3)) : 1);
|
function recalculateScale(newYield: number) {
|
||||||
const denominator = props.basicYieldNum != null ? parseFloat(props.basicYieldNum.toFixed(32)) : 1;
|
if (isNaN(newYield) || newYield <= 0) {
|
||||||
const numberParsed = !!props.basicYieldNum;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => numerator.value, () => {
|
if (props.recipeServings <= 0) {
|
||||||
scale.value = parseFloat((numerator.value / denominator).toFixed(32));
|
scale.value = 1;
|
||||||
|
} else {
|
||||||
|
scale.value = newYield / props.recipeServings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipeYieldAmount = computed(() => {
|
||||||
|
return useScaledAmount(props.recipeServings, scale.value);
|
||||||
});
|
});
|
||||||
|
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
|
||||||
|
const yieldDisplay = computed(() => {
|
||||||
|
return yieldQuantity.value ? i18n.t(
|
||||||
|
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay }
|
||||||
|
) as string : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// only update yield quantity when the menu opens, so we don't override the user's input
|
||||||
|
const yieldQuantityEditorValue = ref(recipeYieldAmount.value.scaledAmount);
|
||||||
|
watch(
|
||||||
|
() => menu.value,
|
||||||
|
() => {
|
||||||
|
if (!menu.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
yieldQuantityEditorValue.value = recipeYieldAmount.value.scaledAmount;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const disableDecrement = computed(() => {
|
const disableDecrement = computed(() => {
|
||||||
return numerator.value <= 1;
|
return recipeYieldAmount.value.scaledAmount <= 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
menu,
|
menu,
|
||||||
|
canEditScale,
|
||||||
scale,
|
scale,
|
||||||
numerator,
|
recalculateScale,
|
||||||
|
yieldDisplay,
|
||||||
|
yieldQuantity,
|
||||||
|
yieldQuantityEditorValue,
|
||||||
disableDecrement,
|
disableDecrement,
|
||||||
numberParsed,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,26 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div v-if="stacked">
|
||||||
|
<v-container>
|
||||||
|
<v-row v-for="(time, index) in allTimes" :key="`${index}-stacked`" no-gutters>
|
||||||
|
<v-col cols="12" :class="containerClass">
|
||||||
|
<v-chip
|
||||||
|
:small="$vuetify.breakpoint.smAndDown"
|
||||||
|
label
|
||||||
|
:color="color"
|
||||||
|
class="ma-1"
|
||||||
|
>
|
||||||
|
<v-icon left>
|
||||||
|
{{ $globals.icons.clockOutline }}
|
||||||
|
</v-icon>
|
||||||
|
{{ time.name }} |
|
||||||
|
{{ time.value }}
|
||||||
|
</v-chip>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<v-container :class="containerClass">
|
||||||
<v-chip
|
<v-chip
|
||||||
v-for="(time, index) in allTimes"
|
v-for="(time, index) in allTimes"
|
||||||
:key="index"
|
:key="index"
|
||||||
|
@ -14,6 +35,7 @@
|
||||||
{{ time.name }} |
|
{{ time.name }} |
|
||||||
{{ time.value }}
|
{{ time.value }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
|
</v-container>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -22,6 +44,10 @@ import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
|
stacked: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
prepTime: {
|
prepTime: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
|
@ -38,6 +64,10 @@ export default defineComponent({
|
||||||
type: String,
|
type: String,
|
||||||
default: "accent custom-transparent"
|
default: "accent custom-transparent"
|
||||||
},
|
},
|
||||||
|
containerClass: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { i18n } = useContext();
|
const { i18n } = useContext();
|
||||||
|
|
69
frontend/components/Domain/Recipe/RecipeYield.vue
Normal file
69
frontend/components/Domain/Recipe/RecipeYield.vue
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="displayText" class="d-flex justify-space-between align-center">
|
||||||
|
<v-chip
|
||||||
|
:small="$vuetify.breakpoint.smAndDown"
|
||||||
|
label
|
||||||
|
:color="color"
|
||||||
|
>
|
||||||
|
<v-icon left>
|
||||||
|
{{ $globals.icons.potSteam }}
|
||||||
|
</v-icon>
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<span v-html="displayText"></span>
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
yieldQuantity: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
yield: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
scale: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: "accent custom-transparent"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { i18n } = useContext();
|
||||||
|
|
||||||
|
function sanitizeHTML(rawHtml: string) {
|
||||||
|
return DOMPurify.sanitize(rawHtml, {
|
||||||
|
USE_PROFILES: { html: true },
|
||||||
|
ALLOWED_TAGS: ["strong", "sup"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayText = computed(() => {
|
||||||
|
if (!(props.yieldQuantity || props.yield)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
|
||||||
|
|
||||||
|
return i18n.t("recipe.yields-amount-with-text", {
|
||||||
|
amount: scaledAmountDisplay,
|
||||||
|
text: sanitizeHTML(props.yield),
|
||||||
|
}) as string;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayText,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -1,111 +0,0 @@
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import { useExtractRecipeYield } from "./use-extract-recipe-yield";
|
|
||||||
|
|
||||||
describe("test use extract recipe yield", () => {
|
|
||||||
test("when text empty return empty", () => {
|
|
||||||
const result = useExtractRecipeYield(null, 1);
|
|
||||||
expect(result).toStrictEqual("");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("when text matches nothing return text", () => {
|
|
||||||
const val = "this won't match anything";
|
|
||||||
const result = useExtractRecipeYield(val, 1);
|
|
||||||
expect(result).toStrictEqual(val);
|
|
||||||
|
|
||||||
const resultScaled = useExtractRecipeYield(val, 5);
|
|
||||||
expect(resultScaled).toStrictEqual(val);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("when text matches a mixed fraction, return a scaled fraction", () => {
|
|
||||||
const val = "10 1/2 units";
|
|
||||||
const result = useExtractRecipeYield(val, 1);
|
|
||||||
expect(result).toStrictEqual(val);
|
|
||||||
|
|
||||||
const resultScaled = useExtractRecipeYield(val, 3);
|
|
||||||
expect(resultScaled).toStrictEqual("31 1/2 units");
|
|
||||||
|
|
||||||
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
|
|
||||||
expect(resultScaledPartial).toStrictEqual("26 1/4 units");
|
|
||||||
|
|
||||||
const resultScaledInt = useExtractRecipeYield(val, 4);
|
|
||||||
expect(resultScaledInt).toStrictEqual("42 units");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("when text matches a fraction, return a scaled fraction", () => {
|
|
||||||
const val = "1/3 plates";
|
|
||||||
const result = useExtractRecipeYield(val, 1);
|
|
||||||
expect(result).toStrictEqual(val);
|
|
||||||
|
|
||||||
const resultScaled = useExtractRecipeYield(val, 2);
|
|
||||||
expect(resultScaled).toStrictEqual("2/3 plates");
|
|
||||||
|
|
||||||
const resultScaledInt = useExtractRecipeYield(val, 3);
|
|
||||||
expect(resultScaledInt).toStrictEqual("1 plates");
|
|
||||||
|
|
||||||
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
|
|
||||||
expect(resultScaledPartial).toStrictEqual("5/6 plates");
|
|
||||||
|
|
||||||
const resultScaledMixed = useExtractRecipeYield(val, 4);
|
|
||||||
expect(resultScaledMixed).toStrictEqual("1 1/3 plates");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("when text matches a decimal, return a scaled, rounded decimal", () => {
|
|
||||||
const val = "1.25 parts";
|
|
||||||
const result = useExtractRecipeYield(val, 1);
|
|
||||||
expect(result).toStrictEqual(val);
|
|
||||||
|
|
||||||
const resultScaled = useExtractRecipeYield(val, 2);
|
|
||||||
expect(resultScaled).toStrictEqual("2.5 parts");
|
|
||||||
|
|
||||||
const resultScaledInt = useExtractRecipeYield(val, 4);
|
|
||||||
expect(resultScaledInt).toStrictEqual("5 parts");
|
|
||||||
|
|
||||||
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
|
|
||||||
expect(resultScaledPartial).toStrictEqual("3.125 parts");
|
|
||||||
|
|
||||||
const roundedVal = "1.33333333333333333333 parts";
|
|
||||||
const resultScaledRounded = useExtractRecipeYield(roundedVal, 2);
|
|
||||||
expect(resultScaledRounded).toStrictEqual("2.667 parts");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("when text matches an int, return a scaled int", () => {
|
|
||||||
const val = "5 bowls";
|
|
||||||
const result = useExtractRecipeYield(val, 1);
|
|
||||||
expect(result).toStrictEqual(val);
|
|
||||||
|
|
||||||
const resultScaled = useExtractRecipeYield(val, 2);
|
|
||||||
expect(resultScaled).toStrictEqual("10 bowls");
|
|
||||||
|
|
||||||
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
|
|
||||||
expect(resultScaledPartial).toStrictEqual("12.5 bowls");
|
|
||||||
|
|
||||||
const resultScaledLarge = useExtractRecipeYield(val, 10);
|
|
||||||
expect(resultScaledLarge).toStrictEqual("50 bowls");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("when text contains an invalid fraction, return the original string", () => {
|
|
||||||
const valDivZero = "3/0 servings";
|
|
||||||
const resultDivZero = useExtractRecipeYield(valDivZero, 3);
|
|
||||||
expect(resultDivZero).toStrictEqual(valDivZero);
|
|
||||||
|
|
||||||
const valDivZeroMixed = "2 4/0 servings";
|
|
||||||
const resultDivZeroMixed = useExtractRecipeYield(valDivZeroMixed, 6);
|
|
||||||
expect(resultDivZeroMixed).toStrictEqual(valDivZeroMixed);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("when text contains a weird or small fraction, return the original string", () => {
|
|
||||||
const valWeird = "2323231239087/134527431962272135 servings";
|
|
||||||
const resultWeird = useExtractRecipeYield(valWeird, 5);
|
|
||||||
expect(resultWeird).toStrictEqual(valWeird);
|
|
||||||
|
|
||||||
const valSmall = "1/20230225 lovable servings";
|
|
||||||
const resultSmall = useExtractRecipeYield(valSmall, 12);
|
|
||||||
expect(resultSmall).toStrictEqual(valSmall);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("when text contains multiple numbers, the first is parsed as the servings amount", () => {
|
|
||||||
const val = "100 sets of 55 bowls";
|
|
||||||
const result = useExtractRecipeYield(val, 3);
|
|
||||||
expect(result).toStrictEqual("300 sets of 55 bowls");
|
|
||||||
})
|
|
||||||
});
|
|
|
@ -1,132 +0,0 @@
|
||||||
import { useFraction } from "~/composables/recipes";
|
|
||||||
|
|
||||||
const matchMixedFraction = /(?:\d*\s\d*\d*|0)\/\d*\d*/;
|
|
||||||
const matchFraction = /(?:\d*\d*|0)\/\d*\d*/;
|
|
||||||
const matchDecimal = /(\d+.\d+)|(.\d+)/;
|
|
||||||
const matchInt = /\d+/;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function extractServingsFromMixedFraction(fractionString: string): number | undefined {
|
|
||||||
const mixedSplit = fractionString.split(/\s/);
|
|
||||||
const wholeNumber = parseInt(mixedSplit[0]);
|
|
||||||
const fraction = mixedSplit[1];
|
|
||||||
|
|
||||||
const fractionSplit = fraction.split("/");
|
|
||||||
const numerator = parseInt(fractionSplit[0]);
|
|
||||||
const denominator = parseInt(fractionSplit[1]);
|
|
||||||
|
|
||||||
if (denominator === 0) {
|
|
||||||
return undefined; // if the denominator is zero, just give up
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return wholeNumber + (numerator / denominator);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractServingsFromFraction(fractionString: string): number | undefined {
|
|
||||||
const fractionSplit = fractionString.split("/");
|
|
||||||
const numerator = parseInt(fractionSplit[0]);
|
|
||||||
const denominator = parseInt(fractionSplit[1]);
|
|
||||||
|
|
||||||
if (denominator === 0) {
|
|
||||||
return undefined; // if the denominator is zero, just give up
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return numerator / denominator;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function findMatch(yieldString: string): [matchString: string, servings: number, isFraction: boolean] | null {
|
|
||||||
if (!yieldString) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mixedFractionMatch = yieldString.match(matchMixedFraction);
|
|
||||||
if (mixedFractionMatch?.length) {
|
|
||||||
const match = mixedFractionMatch[0];
|
|
||||||
const servings = extractServingsFromMixedFraction(match);
|
|
||||||
|
|
||||||
// if the denominator is zero, return no match
|
|
||||||
if (servings === undefined) {
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
return [match, servings, true];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fractionMatch = yieldString.match(matchFraction);
|
|
||||||
if (fractionMatch?.length) {
|
|
||||||
const match = fractionMatch[0]
|
|
||||||
const servings = extractServingsFromFraction(match);
|
|
||||||
|
|
||||||
// if the denominator is zero, return no match
|
|
||||||
if (servings === undefined) {
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
return [match, servings, true];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const decimalMatch = yieldString.match(matchDecimal);
|
|
||||||
if (decimalMatch?.length) {
|
|
||||||
const match = decimalMatch[0];
|
|
||||||
return [match, parseFloat(match), false];
|
|
||||||
}
|
|
||||||
|
|
||||||
const intMatch = yieldString.match(matchInt);
|
|
||||||
if (intMatch?.length) {
|
|
||||||
const match = intMatch[0];
|
|
||||||
return [match, parseInt(match), false];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatServings(servings: number, scale: number, isFraction: boolean): string {
|
|
||||||
const val = servings * scale;
|
|
||||||
if (Number.isInteger(val)) {
|
|
||||||
return val.toString();
|
|
||||||
} else if (!isFraction) {
|
|
||||||
return (Math.round(val * 1000) / 1000).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// convert val into a fraction string
|
|
||||||
const { frac } = useFraction();
|
|
||||||
|
|
||||||
let valString = "";
|
|
||||||
const fraction = frac(val, 10, true);
|
|
||||||
|
|
||||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
|
||||||
valString += fraction[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fraction[1] > 0) {
|
|
||||||
valString += ` ${fraction[1]}/${fraction[2]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return valString.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useExtractRecipeYield(yieldString: string | null, scale: number): string {
|
|
||||||
if (!yieldString) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = findMatch(yieldString);
|
|
||||||
if (!match) {
|
|
||||||
return yieldString;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [matchString, servings, isFraction] = match;
|
|
||||||
|
|
||||||
const formattedServings = formatServings(servings, scale, isFraction);
|
|
||||||
if (!formattedServings) {
|
|
||||||
return yieldString // this only happens with very weird or small fractions
|
|
||||||
} else {
|
|
||||||
return yieldString.replace(matchString, formatServings(servings, scale, isFraction));
|
|
||||||
}
|
|
||||||
}
|
|
68
frontend/composables/recipes/use-scaled-amount.test.ts
Normal file
68
frontend/composables/recipes/use-scaled-amount.test.ts
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { useScaledAmount } from "./use-scaled-amount";
|
||||||
|
|
||||||
|
describe("test use recipe yield", () => {
|
||||||
|
function asFrac(numerator: number, denominator: number): string {
|
||||||
|
return `<sup>${numerator}</sup><span>⁄</span><sub>${denominator}</sub>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("base case", () => {
|
||||||
|
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3);
|
||||||
|
expect(scaledAmount).toStrictEqual(3);
|
||||||
|
expect(scaledAmountDisplay).toStrictEqual("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("base case scaled", () => {
|
||||||
|
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3, 2);
|
||||||
|
expect(scaledAmount).toStrictEqual(6);
|
||||||
|
expect(scaledAmountDisplay).toStrictEqual("6");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero scale", () => {
|
||||||
|
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3, 0);
|
||||||
|
expect(scaledAmount).toStrictEqual(0);
|
||||||
|
expect(scaledAmountDisplay).toStrictEqual("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero quantity", () => {
|
||||||
|
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0);
|
||||||
|
expect(scaledAmount).toStrictEqual(0);
|
||||||
|
expect(scaledAmountDisplay).toStrictEqual("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("basic fraction", () => {
|
||||||
|
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0.5);
|
||||||
|
expect(scaledAmount).toStrictEqual(0.5);
|
||||||
|
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mixed fraction", () => {
|
||||||
|
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.5);
|
||||||
|
expect(scaledAmount).toStrictEqual(1.5);
|
||||||
|
expect(scaledAmountDisplay).toStrictEqual(`1${asFrac(1, 2)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mixed fraction scaled", () => {
|
||||||
|
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.5, 9);
|
||||||
|
expect(scaledAmount).toStrictEqual(13.5);
|
||||||
|
expect(scaledAmountDisplay).toStrictEqual(`13${asFrac(1, 2)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("small scale", () => {
|
||||||
|
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1, 0.125);
|
||||||
|
expect(scaledAmount).toStrictEqual(0.125);
|
||||||
|
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 8));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("small qty", () => {
|
||||||
|
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0.125);
|
||||||
|
expect(scaledAmount).toStrictEqual(0.125);
|
||||||
|
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 8));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rounded decimal", () => {
|
||||||
|
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.3344559997);
|
||||||
|
expect(scaledAmount).toStrictEqual(1.334);
|
||||||
|
expect(scaledAmountDisplay).toStrictEqual(`1${asFrac(1, 3)}`);
|
||||||
|
});
|
||||||
|
});
|
32
frontend/composables/recipes/use-scaled-amount.ts
Normal file
32
frontend/composables/recipes/use-scaled-amount.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { useFraction } from "~/composables/recipes";
|
||||||
|
|
||||||
|
function formatQuantity(val: number): string {
|
||||||
|
if (Number.isInteger(val)) {
|
||||||
|
return val.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { frac } = useFraction();
|
||||||
|
|
||||||
|
let valString = "";
|
||||||
|
const fraction = frac(val, 10, true);
|
||||||
|
|
||||||
|
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||||
|
valString += fraction[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fraction[1] > 0) {
|
||||||
|
valString += `<sup>${fraction[1]}</sup><span>⁄</span><sub>${fraction[2]}</sub>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return valString.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScaledAmount(amount: number, scale = 1) {
|
||||||
|
const scaledAmount = Number(((amount || 0) * scale).toFixed(3));
|
||||||
|
const scaledAmountDisplay = scaledAmount ? formatQuantity(scaledAmount) : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
scaledAmount,
|
||||||
|
scaledAmountDisplay,
|
||||||
|
};
|
||||||
|
}
|
|
@ -517,6 +517,7 @@
|
||||||
"save-recipe-before-use": "Save recipe before use",
|
"save-recipe-before-use": "Save recipe before use",
|
||||||
"section-title": "Section Title",
|
"section-title": "Section Title",
|
||||||
"servings": "Servings",
|
"servings": "Servings",
|
||||||
|
"serves-amount": "Serves {amount}",
|
||||||
"share-recipe-message": "I wanted to share my {0} recipe with you.",
|
"share-recipe-message": "I wanted to share my {0} recipe with you.",
|
||||||
"show-nutrition-values": "Show Nutrition Values",
|
"show-nutrition-values": "Show Nutrition Values",
|
||||||
"sodium-content": "Sodium",
|
"sodium-content": "Sodium",
|
||||||
|
@ -545,6 +546,8 @@
|
||||||
"failed-to-add-recipe-to-mealplan": "Failed to add recipe to mealplan",
|
"failed-to-add-recipe-to-mealplan": "Failed to add recipe to mealplan",
|
||||||
"failed-to-add-to-list": "Failed to add to list",
|
"failed-to-add-to-list": "Failed to add to list",
|
||||||
"yield": "Yield",
|
"yield": "Yield",
|
||||||
|
"yields-amount-with-text": "Yields {amount} {text}",
|
||||||
|
"yield-text": "Yield Text",
|
||||||
"quantity": "Quantity",
|
"quantity": "Quantity",
|
||||||
"choose-unit": "Choose Unit",
|
"choose-unit": "Choose Unit",
|
||||||
"press-enter-to-create": "Press Enter to Create",
|
"press-enter-to-create": "Press Enter to Create",
|
||||||
|
@ -640,7 +643,9 @@
|
||||||
"recipe-debugger-use-openai-description": "Use OpenAI to parse the results instead of relying on the scraper library. When creating a recipe via URL, this is done automatically if the scraper library fails, but you may test it manually here.",
|
"recipe-debugger-use-openai-description": "Use OpenAI to parse the results instead of relying on the scraper library. When creating a recipe via URL, this is done automatically if the scraper library fails, but you may test it manually here.",
|
||||||
"debug": "Debug",
|
"debug": "Debug",
|
||||||
"tree-view": "Tree View",
|
"tree-view": "Tree View",
|
||||||
|
"recipe-servings": "Recipe Servings",
|
||||||
"recipe-yield": "Recipe Yield",
|
"recipe-yield": "Recipe Yield",
|
||||||
|
"recipe-yield-text": "Recipe Yield Text",
|
||||||
"unit": "Unit",
|
"unit": "Unit",
|
||||||
"upload-image": "Upload image",
|
"upload-image": "Upload image",
|
||||||
"screen-awake": "Keep Screen Awake",
|
"screen-awake": "Keep Screen Awake",
|
||||||
|
|
|
@ -126,6 +126,8 @@ export interface RecipeSummary {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
image?: unknown;
|
image?: unknown;
|
||||||
|
recipeServings?: number;
|
||||||
|
recipeYieldQuantity?: number;
|
||||||
recipeYield?: string | null;
|
recipeYield?: string | null;
|
||||||
totalTime?: string | null;
|
totalTime?: string | null;
|
||||||
prepTime?: string | null;
|
prepTime?: string | null;
|
||||||
|
|
|
@ -62,6 +62,8 @@ export interface RecipeSummary {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
image?: unknown;
|
image?: unknown;
|
||||||
|
recipeServings?: number;
|
||||||
|
recipeYieldQuantity?: number;
|
||||||
recipeYield?: string | null;
|
recipeYield?: string | null;
|
||||||
totalTime?: string | null;
|
totalTime?: string | null;
|
||||||
prepTime?: string | null;
|
prepTime?: string | null;
|
||||||
|
|
|
@ -87,6 +87,8 @@ export interface RecipeSummary {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
image?: unknown;
|
image?: unknown;
|
||||||
|
recipeServings?: number;
|
||||||
|
recipeYieldQuantity?: number;
|
||||||
recipeYield?: string | null;
|
recipeYield?: string | null;
|
||||||
totalTime?: string | null;
|
totalTime?: string | null;
|
||||||
prepTime?: string | null;
|
prepTime?: string | null;
|
||||||
|
|
|
@ -230,6 +230,8 @@ export interface Recipe {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
image?: unknown;
|
image?: unknown;
|
||||||
|
recipeServings?: number;
|
||||||
|
recipeYieldQuantity?: number;
|
||||||
recipeYield?: string | null;
|
recipeYield?: string | null;
|
||||||
totalTime?: string | null;
|
totalTime?: string | null;
|
||||||
prepTime?: string | null;
|
prepTime?: string | null;
|
||||||
|
@ -307,6 +309,8 @@ export interface RecipeSummary {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
image?: unknown;
|
image?: unknown;
|
||||||
|
recipeServings?: number;
|
||||||
|
recipeYieldQuantity?: number;
|
||||||
recipeYield?: string | null;
|
recipeYield?: string | null;
|
||||||
totalTime?: string | null;
|
totalTime?: string | null;
|
||||||
prepTime?: string | null;
|
prepTime?: string | null;
|
||||||
|
|
|
@ -218,6 +218,8 @@ export default defineComponent({
|
||||||
tags: true,
|
tags: true,
|
||||||
tools: true,
|
tools: true,
|
||||||
categories: true,
|
categories: true,
|
||||||
|
recipeServings: false,
|
||||||
|
recipeYieldQuantity: false,
|
||||||
recipeYield: false,
|
recipeYield: false,
|
||||||
dateAdded: false,
|
dateAdded: false,
|
||||||
});
|
});
|
||||||
|
@ -228,7 +230,9 @@ export default defineComponent({
|
||||||
tags: i18n.t("tag.tags"),
|
tags: i18n.t("tag.tags"),
|
||||||
categories: i18n.t("recipe.categories"),
|
categories: i18n.t("recipe.categories"),
|
||||||
tools: i18n.t("tool.tools"),
|
tools: i18n.t("tool.tools"),
|
||||||
recipeYield: i18n.t("recipe.recipe-yield"),
|
recipeServings: i18n.t("recipe.recipe-servings"),
|
||||||
|
recipeYieldQuantity: i18n.t("recipe.recipe-yield"),
|
||||||
|
recipeYield: i18n.t("recipe.recipe-yield-text"),
|
||||||
dateAdded: i18n.t("general.date-added"),
|
dateAdded: i18n.t("general.date-added"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -89,7 +89,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||||
cook_time: Mapped[str | None] = mapped_column(sa.String)
|
cook_time: Mapped[str | None] = mapped_column(sa.String)
|
||||||
|
|
||||||
recipe_yield: Mapped[str | None] = mapped_column(sa.String)
|
recipe_yield: Mapped[str | None] = mapped_column(sa.String)
|
||||||
recipeCuisine: Mapped[str | None] = mapped_column(sa.String)
|
recipe_yield_quantity: Mapped[float] = mapped_column(sa.Float, index=True, default=0)
|
||||||
|
recipe_servings: Mapped[float] = mapped_column(sa.Float, index=True, default=0)
|
||||||
|
|
||||||
assets: Mapped[list[RecipeAsset]] = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
|
assets: Mapped[list[RecipeAsset]] = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
|
||||||
nutrition: Mapped[Nutrition] = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
|
nutrition: Mapped[Nutrition] = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
|
||||||
|
@ -131,7 +132,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||||
notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan")
|
notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan")
|
||||||
org_url: Mapped[str | None] = mapped_column(sa.String)
|
org_url: Mapped[str | None] = mapped_column(sa.String)
|
||||||
extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
|
extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
|
||||||
is_ocr_recipe: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
|
||||||
|
|
||||||
# Time Stamp Properties
|
# Time Stamp Properties
|
||||||
date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today)
|
date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today)
|
||||||
|
@ -167,6 +167,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Deprecated
|
||||||
|
recipeCuisine: Mapped[str | None] = mapped_column(sa.String)
|
||||||
|
is_ocr_recipe: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||||
|
|
||||||
@validates("name")
|
@validates("name")
|
||||||
def validate_name(self, _, name):
|
def validate_name(self, _, name):
|
||||||
assert name != ""
|
assert name != ""
|
||||||
|
|
|
@ -8,6 +8,14 @@
|
||||||
"recipe-defaults": {
|
"recipe-defaults": {
|
||||||
"ingredient-note": "1 Cup Flour",
|
"ingredient-note": "1 Cup Flour",
|
||||||
"step-text": "Recipe steps as well as other fields in the recipe page support markdown syntax.\n\n**Add a link**\n\n[My Link](https://demo.mealie.io)\n"
|
"step-text": "Recipe steps as well as other fields in the recipe page support markdown syntax.\n\n**Add a link**\n\n[My Link](https://demo.mealie.io)\n"
|
||||||
|
},
|
||||||
|
"servings-text": {
|
||||||
|
"makes": "Makes",
|
||||||
|
"serves": "Serves",
|
||||||
|
"serving": "Serving",
|
||||||
|
"servings": "Servings",
|
||||||
|
"yield": "Yield",
|
||||||
|
"yields": "Yields"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mealplan": {
|
"mealplan": {
|
||||||
|
|
|
@ -29,3 +29,9 @@ def local_provider(accept_language: str | None = Header(None)) -> Translator:
|
||||||
factory = _load_factory()
|
factory = _load_factory()
|
||||||
accept_language = accept_language or "en-US"
|
accept_language = accept_language or "en-US"
|
||||||
return factory.get(accept_language)
|
return factory.get(accept_language)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_all_translations(key: str) -> dict[str, str]:
|
||||||
|
factory = _load_factory()
|
||||||
|
return {locale: factory.get(locale).t(key) for locale in factory.supported_locales}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from functools import cached_property
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .json_provider import JsonProvider
|
from .json_provider import JsonProvider
|
||||||
|
@ -10,7 +11,7 @@ class InUseProvider:
|
||||||
locks: int
|
locks: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass
|
||||||
class ProviderFactory:
|
class ProviderFactory:
|
||||||
directory: Path
|
directory: Path
|
||||||
fallback_locale: str = "en-US"
|
fallback_locale: str = "en-US"
|
||||||
|
@ -22,6 +23,10 @@ class ProviderFactory:
|
||||||
def fallback_file(self) -> Path:
|
def fallback_file(self) -> Path:
|
||||||
return self.directory / self.filename_format.format(locale=self.fallback_locale, format="json")
|
return self.directory / self.filename_format.format(locale=self.fallback_locale, format="json")
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def supported_locales(self) -> list[str]:
|
||||||
|
return [path.stem for path in self.directory.glob(self.filename_format.format(locale="*", format="json"))]
|
||||||
|
|
||||||
def _load(self, locale: str) -> JsonProvider:
|
def _load(self, locale: str) -> JsonProvider:
|
||||||
filename = self.filename_format.format(locale=locale, format="json")
|
filename = self.filename_format.format(locale=locale, format="json")
|
||||||
path = self.directory / filename
|
path = self.directory / filename
|
||||||
|
|
|
@ -116,7 +116,7 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str:
|
||||||
"prepTime": recipe.prep_time,
|
"prepTime": recipe.prep_time,
|
||||||
"cookTime": recipe.cook_time,
|
"cookTime": recipe.cook_time,
|
||||||
"totalTime": recipe.total_time,
|
"totalTime": recipe.total_time,
|
||||||
"recipeYield": recipe.recipe_yield,
|
"recipeYield": recipe.recipe_yield_display,
|
||||||
"recipeIngredient": ingredients,
|
"recipeIngredient": ingredients,
|
||||||
"recipeInstructions": [i.text for i in recipe.recipe_instructions] if recipe.recipe_instructions else [],
|
"recipeInstructions": [i.text for i in recipe.recipe_instructions] if recipe.recipe_instructions else [],
|
||||||
"recipeCategory": [c.name for c in recipe.recipe_category] if recipe.recipe_category else [],
|
"recipeCategory": [c.name for c in recipe.recipe_category] if recipe.recipe_category else [],
|
||||||
|
|
|
@ -91,6 +91,8 @@ class RecipeSummary(MealieModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
slug: Annotated[str, Field(validate_default=True)] = ""
|
slug: Annotated[str, Field(validate_default=True)] = ""
|
||||||
image: Any | None = None
|
image: Any | None = None
|
||||||
|
recipe_servings: float = 0
|
||||||
|
recipe_yield_quantity: float = 0
|
||||||
recipe_yield: str | None = None
|
recipe_yield: str | None = None
|
||||||
|
|
||||||
total_time: str | None = None
|
total_time: str | None = None
|
||||||
|
@ -122,6 +124,10 @@ class RecipeSummary(MealieModel):
|
||||||
|
|
||||||
return val
|
return val
|
||||||
|
|
||||||
|
@property
|
||||||
|
def recipe_yield_display(self) -> str:
|
||||||
|
return f"{self.recipe_yield_quantity} {self.recipe_yield}".strip()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def loader_options(cls) -> list[LoaderOption]:
|
def loader_options(cls) -> list[LoaderOption]:
|
||||||
return [
|
return [
|
||||||
|
|
|
@ -92,10 +92,8 @@ class TandoorMigrator(BaseMigrator):
|
||||||
recipe_data.pop("working_time", 0), recipe_data.pop("waiting_time", 0)
|
recipe_data.pop("working_time", 0), recipe_data.pop("waiting_time", 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
serving_size = recipe_data.pop("servings", 0)
|
recipe_data["recipeYieldQuantity"] = recipe_data.pop("servings", 0)
|
||||||
serving_text = recipe_data.pop("servings_text", "")
|
recipe_data["recipeYield"] = recipe_data.pop("servings_text", "")
|
||||||
if serving_size and serving_text:
|
|
||||||
recipe_data["recipeYield"] = f"{serving_size} {serving_text}"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
recipe_image_path = next(source_dir.glob("image.*"))
|
recipe_image_path = next(source_dir.glob("image.*"))
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
import re
|
|
||||||
|
|
||||||
compiled_match = re.compile(r"(.){1,6}\s\((.[^\(\)])+\)\s")
|
|
||||||
compiled_search = re.compile(r"\((.[^\(])+\)")
|
|
||||||
|
|
||||||
|
|
||||||
def move_parens_to_end(ing_str) -> str:
|
|
||||||
"""
|
|
||||||
Moves all parentheses in the string to the end of the string using Regex.
|
|
||||||
If no parentheses are found, the string is returned unchanged.
|
|
||||||
"""
|
|
||||||
if re.match(compiled_match, ing_str):
|
|
||||||
if match := re.search(compiled_search, ing_str):
|
|
||||||
start = match.start()
|
|
||||||
end = match.end()
|
|
||||||
ing_str = ing_str[:start] + ing_str[end:] + " " + ing_str[start:end]
|
|
||||||
|
|
||||||
return ing_str
|
|
||||||
|
|
||||||
|
|
||||||
def check_char(char, *eql) -> bool:
|
|
||||||
"""Helper method to check if a characters matches any of the additional provided arguments"""
|
|
||||||
return any(char == eql_char for eql_char in eql)
|
|
|
@ -3,7 +3,7 @@ import unicodedata
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
from .._helpers import check_char, move_parens_to_end
|
from ..parser_utils import check_char, move_parens_to_end
|
||||||
|
|
||||||
|
|
||||||
class BruteParsedIngredient(BaseModel):
|
class BruteParsedIngredient(BaseModel):
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
|
||||||
|
from mealie.services.parser_services.parser_utils import convert_vulgar_fractions_to_regular_fractions
|
||||||
|
|
||||||
replace_abbreviations = {
|
replace_abbreviations = {
|
||||||
"cup": " cup ",
|
"cup": " cup ",
|
||||||
|
@ -29,23 +30,6 @@ def remove_periods(string: str) -> str:
|
||||||
return re.sub(r"(?<!\d)\.(?!\d)", "", string)
|
return re.sub(r"(?<!\d)\.(?!\d)", "", string)
|
||||||
|
|
||||||
|
|
||||||
def replace_fraction_unicode(string: str):
|
|
||||||
# TODO: I'm not confident this works well enough for production needs some testing and/or refacorting
|
|
||||||
# TODO: Breaks on multiple unicode fractions
|
|
||||||
for c in string:
|
|
||||||
try:
|
|
||||||
name = unicodedata.name(c)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if name.startswith("VULGAR FRACTION"):
|
|
||||||
normalized = unicodedata.normalize("NFKC", c)
|
|
||||||
numerator, _, denominator = normalized.partition("⁄") # _ = slash
|
|
||||||
text = f" {numerator}/{denominator}"
|
|
||||||
return string.replace(c, text).replace(" ", " ")
|
|
||||||
|
|
||||||
return string
|
|
||||||
|
|
||||||
|
|
||||||
def wrap_or_clause(string: str):
|
def wrap_or_clause(string: str):
|
||||||
"""
|
"""
|
||||||
Attempts to wrap or clauses in ()
|
Attempts to wrap or clauses in ()
|
||||||
|
@ -75,7 +59,7 @@ def pre_process_string(string: str) -> str:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
string = string.lower()
|
string = string.lower()
|
||||||
string = replace_fraction_unicode(string)
|
string = convert_vulgar_fractions_to_regular_fractions(string)
|
||||||
string = remove_periods(string)
|
string = remove_periods(string)
|
||||||
string = replace_common_abbreviations(string)
|
string = replace_common_abbreviations(string)
|
||||||
|
|
||||||
|
|
111
mealie/services/parser_services/parser_utils/string_utils.py
Normal file
111
mealie/services/parser_services/parser_utils/string_utils.py
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import re
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
|
compiled_match = re.compile(r"(.){1,6}\s\((.[^\(\)])+\)\s")
|
||||||
|
compiled_search = re.compile(r"\((.[^\(])+\)")
|
||||||
|
|
||||||
|
|
||||||
|
def move_parens_to_end(ing_str) -> str:
|
||||||
|
"""
|
||||||
|
Moves all parentheses in the string to the end of the string using Regex.
|
||||||
|
If no parentheses are found, the string is returned unchanged.
|
||||||
|
"""
|
||||||
|
if re.match(compiled_match, ing_str):
|
||||||
|
if match := re.search(compiled_search, ing_str):
|
||||||
|
start = match.start()
|
||||||
|
end = match.end()
|
||||||
|
ing_str = ing_str[:start] + ing_str[end:] + " " + ing_str[start:end]
|
||||||
|
|
||||||
|
return ing_str
|
||||||
|
|
||||||
|
|
||||||
|
def check_char(char, *eql) -> bool:
|
||||||
|
"""Helper method to check if a characters matches any of the additional provided arguments"""
|
||||||
|
return any(char == eql_char for eql_char in eql)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_vulgar_fractions_to_regular_fractions(text: str) -> str:
|
||||||
|
vulgar_fractions = {
|
||||||
|
"¼": "1/4",
|
||||||
|
"½": "1/2",
|
||||||
|
"¾": "3/4",
|
||||||
|
"⅐": "1/7",
|
||||||
|
"⅑": "1/9",
|
||||||
|
"⅒": "1/10",
|
||||||
|
"⅓": "1/3",
|
||||||
|
"⅔": "2/3",
|
||||||
|
"⅕": "1/5",
|
||||||
|
"⅖": "2/5",
|
||||||
|
"⅗": "3/5",
|
||||||
|
"⅘": "4/5",
|
||||||
|
"⅙": "1/6",
|
||||||
|
"⅚": "5/6",
|
||||||
|
"⅛": "1/8",
|
||||||
|
"⅜": "3/8",
|
||||||
|
"⅝": "5/8",
|
||||||
|
"⅞": "7/8",
|
||||||
|
}
|
||||||
|
|
||||||
|
for vulgar_fraction, regular_fraction in vulgar_fractions.items():
|
||||||
|
# if we don't add a space in front of the fraction, mixed fractions will be broken
|
||||||
|
# e.g. "1½" -> "11/2"
|
||||||
|
text = text.replace(vulgar_fraction, f" {regular_fraction}").strip()
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def extract_quantity_from_string(source_str: str) -> tuple[float, str]:
|
||||||
|
"""
|
||||||
|
Extracts a quantity from a string. The quantity can be a fraction, decimal, or integer.
|
||||||
|
|
||||||
|
Returns the quantity and the remaining string. If no quantity is found, returns the quantity as 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_str = source_str.strip()
|
||||||
|
if not source_str:
|
||||||
|
return 0, ""
|
||||||
|
|
||||||
|
source_str = convert_vulgar_fractions_to_regular_fractions(source_str)
|
||||||
|
|
||||||
|
mixed_fraction_pattern = re.compile(r"(\d+)\s+(\d+)/(\d+)")
|
||||||
|
fraction_pattern = re.compile(r"(\d+)/(\d+)")
|
||||||
|
number_pattern = re.compile(r"\d+(\.\d+)?")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check for a mixed fraction (e.g. "1 1/2")
|
||||||
|
match = mixed_fraction_pattern.search(source_str)
|
||||||
|
if match:
|
||||||
|
whole_number = int(match.group(1))
|
||||||
|
numerator = int(match.group(2))
|
||||||
|
denominator = int(match.group(3))
|
||||||
|
quantity = whole_number + float(Fraction(numerator, denominator))
|
||||||
|
remaining_str = source_str[: match.start()] + source_str[match.end() :]
|
||||||
|
|
||||||
|
remaining_str = remaining_str.strip()
|
||||||
|
return quantity, remaining_str
|
||||||
|
|
||||||
|
# Check for a fraction (e.g. "1/2")
|
||||||
|
match = fraction_pattern.search(source_str)
|
||||||
|
if match:
|
||||||
|
numerator = int(match.group(1))
|
||||||
|
denominator = int(match.group(2))
|
||||||
|
quantity = float(Fraction(numerator, denominator))
|
||||||
|
remaining_str = source_str[: match.start()] + source_str[match.end() :]
|
||||||
|
|
||||||
|
remaining_str = remaining_str.strip()
|
||||||
|
return quantity, remaining_str
|
||||||
|
|
||||||
|
# Check for a number (integer or float)
|
||||||
|
match = number_pattern.search(source_str)
|
||||||
|
if match:
|
||||||
|
quantity = float(match.group())
|
||||||
|
remaining_str = source_str[: match.start()] + source_str[match.end() :]
|
||||||
|
|
||||||
|
remaining_str = remaining_str.strip()
|
||||||
|
return quantity, remaining_str
|
||||||
|
|
||||||
|
except ZeroDivisionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If no match, return 0 and the original string
|
||||||
|
return 0, source_str
|
|
@ -10,8 +10,9 @@ from datetime import datetime, timedelta
|
||||||
from slugify import slugify
|
from slugify import slugify
|
||||||
|
|
||||||
from mealie.core.root_logger import get_logger
|
from mealie.core.root_logger import get_logger
|
||||||
from mealie.lang.providers import Translator
|
from mealie.lang.providers import Translator, get_all_translations
|
||||||
from mealie.schema.recipe.recipe import Recipe
|
from mealie.schema.recipe.recipe import Recipe
|
||||||
|
from mealie.services.parser_services.parser_utils import extract_quantity_from_string
|
||||||
|
|
||||||
logger = get_logger("recipe-scraper")
|
logger = get_logger("recipe-scraper")
|
||||||
|
|
||||||
|
@ -51,18 +52,21 @@ def clean(recipe_data: Recipe | dict, translator: Translator, url=None) -> Recip
|
||||||
|
|
||||||
recipe_data = recipe_data_dict
|
recipe_data = recipe_data_dict
|
||||||
|
|
||||||
|
recipe_data["slug"] = slugify(recipe_data.get("name", ""))
|
||||||
recipe_data["description"] = clean_string(recipe_data.get("description", ""))
|
recipe_data["description"] = clean_string(recipe_data.get("description", ""))
|
||||||
|
|
||||||
# Times
|
|
||||||
recipe_data["prepTime"] = clean_time(recipe_data.get("prepTime"), translator)
|
recipe_data["prepTime"] = clean_time(recipe_data.get("prepTime"), translator)
|
||||||
recipe_data["performTime"] = clean_time(recipe_data.get("performTime"), translator)
|
recipe_data["performTime"] = clean_time(recipe_data.get("performTime"), translator)
|
||||||
recipe_data["totalTime"] = clean_time(recipe_data.get("totalTime"), translator)
|
recipe_data["totalTime"] = clean_time(recipe_data.get("totalTime"), translator)
|
||||||
|
|
||||||
|
recipe_data["recipeServings"], recipe_data["recipeYieldQuantity"], recipe_data["recipeYield"] = clean_yield(
|
||||||
|
recipe_data.get("recipeYield")
|
||||||
|
)
|
||||||
recipe_data["recipeCategory"] = clean_categories(recipe_data.get("recipeCategory", []))
|
recipe_data["recipeCategory"] = clean_categories(recipe_data.get("recipeCategory", []))
|
||||||
recipe_data["recipeYield"] = clean_yield(recipe_data.get("recipeYield"))
|
|
||||||
recipe_data["recipeIngredient"] = clean_ingredients(recipe_data.get("recipeIngredient", []))
|
recipe_data["recipeIngredient"] = clean_ingredients(recipe_data.get("recipeIngredient", []))
|
||||||
recipe_data["recipeInstructions"] = clean_instructions(recipe_data.get("recipeInstructions", []))
|
recipe_data["recipeInstructions"] = clean_instructions(recipe_data.get("recipeInstructions", []))
|
||||||
|
|
||||||
recipe_data["image"] = clean_image(recipe_data.get("image"))[0]
|
recipe_data["image"] = clean_image(recipe_data.get("image"))[0]
|
||||||
recipe_data["slug"] = slugify(recipe_data.get("name", ""))
|
|
||||||
recipe_data["orgURL"] = url or recipe_data.get("orgURL")
|
recipe_data["orgURL"] = url or recipe_data.get("orgURL")
|
||||||
recipe_data["notes"] = clean_notes(recipe_data.get("notes"))
|
recipe_data["notes"] = clean_notes(recipe_data.get("notes"))
|
||||||
recipe_data["rating"] = clean_int(recipe_data.get("rating"))
|
recipe_data["rating"] = clean_int(recipe_data.get("rating"))
|
||||||
|
@ -324,7 +328,31 @@ def clean_notes(notes: typing.Any) -> list[dict] | None:
|
||||||
return parsed_notes
|
return parsed_notes
|
||||||
|
|
||||||
|
|
||||||
def clean_yield(yld: str | list[str] | None) -> str:
|
@functools.lru_cache
|
||||||
|
def _get_servings_options() -> set[str]:
|
||||||
|
options: set[str] = set()
|
||||||
|
for key in [
|
||||||
|
"recipe.servings-text.makes",
|
||||||
|
"recipe.servings-text.serves",
|
||||||
|
"recipe.servings-text.serving",
|
||||||
|
"recipe.servings-text.servings",
|
||||||
|
"recipe.servings-text.yield",
|
||||||
|
"recipe.servings-text.yields",
|
||||||
|
]:
|
||||||
|
options.update([t.strip().lower() for t in get_all_translations(key).values()])
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def _is_serving_string(txt: str) -> bool:
|
||||||
|
txt = txt.strip().lower()
|
||||||
|
for option in _get_servings_options():
|
||||||
|
if option in txt.strip().lower():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clean_yield(yields: str | list[str] | None) -> tuple[float, float, str]:
|
||||||
"""
|
"""
|
||||||
yield_amount attemps to parse out the yield amount from a recipe.
|
yield_amount attemps to parse out the yield amount from a recipe.
|
||||||
|
|
||||||
|
@ -333,15 +361,34 @@ def clean_yield(yld: str | list[str] | None) -> str:
|
||||||
- `["4 servings", "4 Pies"]` - returns the last value
|
- `["4 servings", "4 Pies"]` - returns the last value
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
float: The servings, if it can be parsed else 0
|
||||||
|
float: The yield quantity, if it can be parsed else 0
|
||||||
str: The yield amount, if it can be parsed else an empty string
|
str: The yield amount, if it can be parsed else an empty string
|
||||||
"""
|
"""
|
||||||
|
servings_qty: float = 0
|
||||||
|
yld_qty: float = 0
|
||||||
|
yld_str = ""
|
||||||
|
|
||||||
|
if not yields:
|
||||||
|
return servings_qty, yld_qty, yld_str
|
||||||
|
|
||||||
|
if not isinstance(yields, list):
|
||||||
|
yields = [yields]
|
||||||
|
|
||||||
|
for yld in yields:
|
||||||
if not yld:
|
if not yld:
|
||||||
return ""
|
continue
|
||||||
|
if not isinstance(yld, str):
|
||||||
|
yld = str(yld)
|
||||||
|
|
||||||
if isinstance(yld, list):
|
qty, txt = extract_quantity_from_string(yld)
|
||||||
return yld[-1]
|
if qty and _is_serving_string(yld):
|
||||||
|
servings_qty = qty
|
||||||
|
else:
|
||||||
|
yld_qty = qty
|
||||||
|
yld_str = txt
|
||||||
|
|
||||||
return yld
|
return servings_qty, yld_qty, yld_str
|
||||||
|
|
||||||
|
|
||||||
def clean_time(time_entry: str | timedelta | None, translator: Translator) -> None | str:
|
def clean_time(time_entry: str | timedelta | None, translator: Translator) -> None | str:
|
||||||
|
|
|
@ -275,22 +275,102 @@ yield_test_cases = (
|
||||||
CleanerCase(
|
CleanerCase(
|
||||||
test_id="empty string",
|
test_id="empty string",
|
||||||
input="",
|
input="",
|
||||||
expected="",
|
expected=(0, 0, ""),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="regular string",
|
||||||
|
input="4 Batches",
|
||||||
|
expected=(0, 4, "Batches"),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="regular serving string",
|
||||||
|
input="4 Servings",
|
||||||
|
expected=(4, 0, ""),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="regular string with whitespace",
|
||||||
|
input="4 Batches ",
|
||||||
|
expected=(0, 4, "Batches"),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="regular serving string with whitespace",
|
||||||
|
input="4 Servings ",
|
||||||
|
expected=(4, 0, ""),
|
||||||
),
|
),
|
||||||
CleanerCase(
|
CleanerCase(
|
||||||
test_id="list of strings",
|
test_id="list of strings",
|
||||||
input=["Makes 4 Batches", "4 Batches"],
|
input=["Serves 2", "4 Batches", "5 Batches"],
|
||||||
expected="4 Batches",
|
expected=(2, 5, "Batches"),
|
||||||
),
|
),
|
||||||
CleanerCase(
|
CleanerCase(
|
||||||
test_id="basic string",
|
test_id="basic string",
|
||||||
|
input="Makes a lot of Batches",
|
||||||
|
expected=(0, 0, "Makes a lot of Batches"),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="basic serving string",
|
||||||
input="Makes 4 Batches",
|
input="Makes 4 Batches",
|
||||||
expected="Makes 4 Batches",
|
expected=(4, 0, ""),
|
||||||
),
|
),
|
||||||
CleanerCase(
|
CleanerCase(
|
||||||
test_id="empty list",
|
test_id="empty list",
|
||||||
input=[],
|
input=[],
|
||||||
expected="",
|
expected=(0, 0, ""),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="basic fraction",
|
||||||
|
input="1/2 Batches",
|
||||||
|
expected=(0, 0.5, "Batches"),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="mixed fraction",
|
||||||
|
input="1 1/2 Batches",
|
||||||
|
expected=(0, 1.5, "Batches"),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="improper fraction",
|
||||||
|
input="11/2 Batches",
|
||||||
|
expected=(0, 5.5, "Batches"),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="vulgar fraction",
|
||||||
|
input="¾ Batches",
|
||||||
|
expected=(0, 0.75, "Batches"),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="mixed vulgar fraction",
|
||||||
|
input="2¾ Batches",
|
||||||
|
expected=(0, 2.75, "Batches"),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="mixed vulgar fraction with space",
|
||||||
|
input="2 ¾ Batches",
|
||||||
|
expected=(0, 2.75, "Batches"),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="basic decimal",
|
||||||
|
input="0.5 Batches",
|
||||||
|
expected=(0, 0.5, "Batches"),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="text with numbers",
|
||||||
|
input="6 Batches or 10 Batches",
|
||||||
|
expected=(0, 6, "Batches or 10 Batches"),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="no qty",
|
||||||
|
input="A Lot of Servings",
|
||||||
|
expected=(0, 0, "A Lot of Servings"),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="invalid qty",
|
||||||
|
input="1/0 Batches",
|
||||||
|
expected=(0, 0, "1/0 Batches"),
|
||||||
|
),
|
||||||
|
CleanerCase(
|
||||||
|
test_id="int as float",
|
||||||
|
input="3.0 Batches",
|
||||||
|
expected=(0, 3, "Batches"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue