mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	feat: Structured Yields (#4489)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
		| @@ -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"), | ||||||
|     ), |     ), | ||||||
| ) | ) | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user