mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -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 mealie.db.migration_types | ||||
| from alembic import op | ||||
|  | ||||
| # 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; | ||||
|   categories: boolean; | ||||
|   tools: boolean; | ||||
|   recipeServings: boolean; | ||||
|   recipeYieldQuantity: boolean; | ||||
|   recipeYield: boolean; | ||||
|   dateAdded: boolean; | ||||
| } | ||||
| @@ -93,6 +95,8 @@ export default defineComponent({ | ||||
|           owner: false, | ||||
|           tags: true, | ||||
|           categories: true, | ||||
|           recipeServings: true, | ||||
|           recipeYieldQuantity: true, | ||||
|           recipeYield: true, | ||||
|           dateAdded: true, | ||||
|         }; | ||||
| @@ -127,8 +131,14 @@ export default defineComponent({ | ||||
|       if (props.showHeaders.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) { | ||||
|         hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYield" }); | ||||
|         hdrs.push({ text: i18n.t("recipe.yield-text"), value: "recipeYield" }); | ||||
|       } | ||||
|       if (props.showHeaders.dateAdded) { | ||||
|         hdrs.push({ text: i18n.t("general.date-added"), value: "dateAdded" }); | ||||
|   | ||||
| @@ -86,12 +86,6 @@ | ||||
|       </BaseDialog> | ||||
|     </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"> | ||||
|         <v-chip | ||||
|           label | ||||
| @@ -105,6 +99,12 @@ | ||||
|             {{ $t('recipe.last-made-date', { date: value ? new Date(value).toLocaleDateString($i18n.locale) : $t("general.never") } ) }} | ||||
|         </v-chip> | ||||
|       </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> | ||||
| </template> | ||||
| @@ -125,7 +125,7 @@ export default defineComponent({ | ||||
|     }, | ||||
|     recipe: { | ||||
|       type: Object as () => Recipe, | ||||
|       default: null, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   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 | ||||
|             data management and mutation system we're using. | ||||
|           --> | ||||
|           <RecipePageInfoEditor v-if="isEditMode" :recipe="recipe" :landscape="landscape" /> | ||||
|           <RecipePageEditorToolbar v-if="isEditForm" :recipe="recipe" /> | ||||
|           <RecipePageTitleContent :recipe="recipe" :landscape="landscape" /> | ||||
|           <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. | ||||
| @@ -76,7 +76,7 @@ | ||||
|       <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%;"> | ||||
|           <div class="d-flex align-center"> | ||||
|             <RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape" /> | ||||
|             <RecipePageScale :recipe="recipe" :scale.sync="scale" /> | ||||
|           </div> | ||||
|           <RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" :is-cook-mode="isCookMode" /> | ||||
|           <v-divider></v-divider> | ||||
| @@ -95,7 +95,7 @@ | ||||
|     </v-sheet> | ||||
|     <v-sheet v-show="isCookMode && hasLinkedIngredients"> | ||||
|       <div class="mt-2 px-2 px-md-4"> | ||||
|         <RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape"/> | ||||
|         <RecipePageScale :recipe="recipe" :scale.sync="scale"/> | ||||
|       </div> | ||||
|       <RecipePageInstructions | ||||
|         v-model="recipe.recipeInstructions" | ||||
| @@ -154,7 +154,7 @@ import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredien | ||||
| import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue"; | ||||
| import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.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 { useLoggedInState } from "~/composables/use-logged-in-state"; | ||||
| import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue"; | ||||
| @@ -185,7 +185,7 @@ export default defineComponent({ | ||||
|     RecipePageHeader, | ||||
|     RecipePrintContainer, | ||||
|     RecipePageComments, | ||||
|     RecipePageTitleContent, | ||||
|     RecipePageInfoEditor, | ||||
|     RecipePageEditorToolbar, | ||||
|     RecipePageIngredientEditor, | ||||
|     RecipePageOrganizers, | ||||
| @@ -195,7 +195,7 @@ export default defineComponent({ | ||||
|     RecipeNotes, | ||||
|     RecipePageInstructions, | ||||
|     RecipePageFooter, | ||||
|     RecipeIngredients | ||||
|     RecipeIngredients, | ||||
|   }, | ||||
|   props: { | ||||
|     recipe: { | ||||
|   | ||||
| @@ -1,46 +1,7 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <div class="d-flex justify-end flex-wrap align-stretch"> | ||||
|       <v-card v-if="!landscape" width="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"></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> | ||||
|     <RecipePageInfoCard :recipe="recipe" :recipe-scale="recipeScale" :landscape="landscape" /> | ||||
|     <v-divider /> | ||||
|     <RecipeActionMenu | ||||
|       :recipe="recipe" | ||||
|       :slug="recipe.slug" | ||||
| @@ -65,10 +26,8 @@ | ||||
| import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api"; | ||||
| import { useLoggedInState } from "~/composables/use-logged-in-state"; | ||||
| import { useRecipePermissions } from "~/composables/recipes"; | ||||
| import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue"; | ||||
| import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue"; | ||||
| import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue"; | ||||
| import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue"; | ||||
| import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue"; | ||||
| import { useStaticRoutes, useUserApi  } from "~/composables/api"; | ||||
| import { HouseholdSummary } from "~/lib/api/types/household"; | ||||
| 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"; | ||||
| export default defineComponent({ | ||||
|   components: { | ||||
|     RecipeTimeCard, | ||||
|     RecipePageInfoCard, | ||||
|     RecipeActionMenu, | ||||
|     RecipeRating, | ||||
|     RecipeLastMade, | ||||
|   }, | ||||
|   props: { | ||||
|     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 | ||||
|           v-model.number="scaleValue" | ||||
|           v-bind="attrs" | ||||
|           :recipe-yield="recipe.recipeYield" | ||||
|           :scaled-yield="scaledYield" | ||||
|           :basic-yield-num="basicYieldNum" | ||||
|           :recipe-servings="recipeServings" | ||||
|           :edit-scale="!recipe.settings.disableAmount && !isEditMode" | ||||
|           v-on="on" | ||||
|         /> | ||||
|       </template> | ||||
|       <span> {{ $t("recipe.edit-scale") }} </span> | ||||
|     </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> | ||||
| </template> | ||||
|  | ||||
| <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 RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue"; | ||||
| import { NoUndefinedField } from "~/lib/api/types/non-generated"; | ||||
| import { Recipe } from "~/lib/api/types/recipe"; | ||||
| import { usePageState } from "~/composables/recipe-page/shared-state"; | ||||
| import { useExtractRecipeYield, findMatch } from "~/composables/recipe-page/use-extract-recipe-yield"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   components: { | ||||
|     RecipeScaleEditButton, | ||||
|     RecipeRating, | ||||
|   }, | ||||
|   props: { | ||||
|     recipe: { | ||||
|       type: Object as () => NoUndefinedField<Recipe>, | ||||
|       required: true, | ||||
|     }, | ||||
|     landscape: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|     scale: { | ||||
|       type: Number, | ||||
|       default: 1, | ||||
| @@ -57,6 +39,10 @@ export default defineComponent({ | ||||
|   setup(props, { emit }) { | ||||
|     const { isEditMode } = usePageState(props.recipe.slug); | ||||
|  | ||||
|     const recipeServings = computed<number>(() => { | ||||
|       return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1; | ||||
|     }); | ||||
|  | ||||
|     const scaleValue = computed<number>({ | ||||
|       get() { | ||||
|         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 { | ||||
|       recipeServings, | ||||
|       scaleValue, | ||||
|       scaledYield, | ||||
|       basicYieldNum, | ||||
|       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> | ||||
|               {{ recipe.name }} | ||||
|             </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"> | ||||
|               <SafeMarkdown :source="recipe.description" /> | ||||
|             </v-card-text> | ||||
| @@ -30,9 +47,6 @@ | ||||
|     <!-- Ingredients --> | ||||
|     <section> | ||||
|       <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 | ||||
|         v-for="(ingredientSection, sectionIndex) in ingredientSections" | ||||
|         :key="`ingredient-section-${sectionIndex}`" | ||||
| @@ -111,7 +125,8 @@ | ||||
| </template> | ||||
|  | ||||
| <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 { useStaticRoutes } from "~/composables/api"; | ||||
| 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 { parseIngredientText, useNutritionLabels } from "~/composables/recipes"; | ||||
| import { usePageState } from "~/composables/recipe-page/shared-state"; | ||||
| import { useScaledAmount } from "~/composables/recipes/use-scaled-amount"; | ||||
|  | ||||
|  | ||||
| type IngredientSection = { | ||||
| @@ -151,13 +167,39 @@ export default defineComponent({ | ||||
|     } | ||||
|   }, | ||||
|   setup(props) { | ||||
|     const { i18n } = useContext(); | ||||
|     const preferences = useUserPrintPreferences(); | ||||
|     const { recipeImage } = useStaticRoutes(); | ||||
|     const { imageKey } = usePageState(props.recipe.slug); | ||||
|     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(() => { | ||||
|       return recipeImage(props.recipe.id, props.recipe.image, imageKey.value); | ||||
| @@ -258,6 +300,7 @@ export default defineComponent({ | ||||
|       parseIngredientText, | ||||
|       preferences, | ||||
|       recipeImageUrl, | ||||
|       recipeYield, | ||||
|       ingredientSections, | ||||
|       instructionSections, | ||||
|     }; | ||||
|   | ||||
| @@ -1,16 +1,13 @@ | ||||
| <template> | ||||
|   <div> | ||||
|   <div v-if="yieldDisplay"> | ||||
|     <div class="text-center d-flex align-center"> | ||||
|       <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 }"> | ||||
|             <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> | ||||
|               <div v-else-if="!numberParsed && recipeYield"> | ||||
|                 <span v-if="numerator === 1"> {{ recipeYield }} </span> | ||||
|                 <span v-else> {{ numerator }}x {{ scaledYield }} </span> | ||||
|               </div> | ||||
|               <span v-else> {{ scaledYield }} </span> | ||||
|               <v-icon small class="mr-2">{{ $globals.icons.edit }}</v-icon> | ||||
|               <!-- eslint-disable-next-line vue/no-v-html --> | ||||
|               <span v-html="yieldDisplay"></span> | ||||
|  | ||||
|             </v-card> | ||||
|           </template> | ||||
| @@ -20,7 +17,7 @@ | ||||
|             </v-card-title> | ||||
|             <v-card-text class="mt-n5"> | ||||
|               <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"> | ||||
|                   <template #activator="{ on, attrs }"> | ||||
|                     <v-btn v-bind="attrs" icon class="mx-1" small v-on="on" @click="scale = 1"> | ||||
| @@ -37,7 +34,7 @@ | ||||
|         </v-menu> | ||||
|       </div> | ||||
|       <BaseButtonGroup | ||||
|         v-if="editScale" | ||||
|         v-if="canEditScale" | ||||
|         class="pl-2" | ||||
|         :large="false" | ||||
|         :buttons="[ | ||||
| @@ -53,41 +50,36 @@ | ||||
|             event: 'increment', | ||||
|           }, | ||||
|         ]" | ||||
|         @decrement="numerator--" | ||||
|         @increment="numerator++" | ||||
|         @decrement="recalculateScale(yieldQuantity - 1)" | ||||
|         @increment="recalculateScale(yieldQuantity + 1)" | ||||
|       /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <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({ | ||||
|   props: { | ||||
|     recipeYield: { | ||||
|       type: String, | ||||
|       default: null, | ||||
|     }, | ||||
|     scaledYield: { | ||||
|       type: String, | ||||
|       default: null, | ||||
|     }, | ||||
|     basicYieldNum: { | ||||
|     value: { | ||||
|       type: Number, | ||||
|       default: null, | ||||
|       required: true, | ||||
|     }, | ||||
|     recipeServings: { | ||||
|       type: Number, | ||||
|       default: 0, | ||||
|     }, | ||||
|     editScale: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|     value: { | ||||
|       type: Number, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   setup(props, { emit }) { | ||||
|     const { i18n } = useContext(); | ||||
|     const menu = ref<boolean>(false); | ||||
|     const canEditScale = computed(() => props.editScale && props.recipeServings > 0); | ||||
|  | ||||
|     const scale = computed({ | ||||
|       get: () => props.value, | ||||
| @@ -97,24 +89,54 @@ export default defineComponent({ | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     const numerator = ref<number>(props.basicYieldNum != null ? parseFloat(props.basicYieldNum.toFixed(3)) : 1); | ||||
|     const denominator = props.basicYieldNum != null ? parseFloat(props.basicYieldNum.toFixed(32)) : 1; | ||||
|     const numberParsed = !!props.basicYieldNum; | ||||
|     function recalculateScale(newYield: number) { | ||||
|       if (isNaN(newYield) || newYield <= 0) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|     watch(() => numerator.value, () => { | ||||
|       scale.value = parseFloat((numerator.value / denominator).toFixed(32)); | ||||
|       if (props.recipeServings <= 0) { | ||||
|         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(() => { | ||||
|       return numerator.value <= 1; | ||||
|       return recipeYieldAmount.value.scaledAmount <= 1; | ||||
|     }); | ||||
|  | ||||
|  | ||||
|     return { | ||||
|       menu, | ||||
|       canEditScale, | ||||
|       scale, | ||||
|       numerator, | ||||
|       recalculateScale, | ||||
|       yieldDisplay, | ||||
|       yieldQuantity, | ||||
|       yieldQuantityEditorValue, | ||||
|       disableDecrement, | ||||
|       numberParsed, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
|   | ||||
| @@ -1,5 +1,26 @@ | ||||
| <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-for="(time, index) in allTimes" | ||||
|         :key="index" | ||||
| @@ -14,6 +35,7 @@ | ||||
|         {{ time.name }} | | ||||
|         {{ time.value }} | ||||
|       </v-chip> | ||||
|     </v-container> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| @@ -22,6 +44,10 @@ import { computed, defineComponent, useContext } from "@nuxtjs/composition-api"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   props: { | ||||
|     stacked: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|     prepTime: { | ||||
|       type: String, | ||||
|       default: null, | ||||
| @@ -38,6 +64,10 @@ export default defineComponent({ | ||||
|       type: String, | ||||
|       default: "accent custom-transparent" | ||||
|     }, | ||||
|     containerClass: { | ||||
|       type: String, | ||||
|       default: undefined, | ||||
|     }, | ||||
|   }, | ||||
|   setup(props) { | ||||
|     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", | ||||
|     "section-title": "Section Title", | ||||
|     "servings": "Servings", | ||||
|     "serves-amount": "Serves {amount}", | ||||
|     "share-recipe-message": "I wanted to share my {0} recipe with you.", | ||||
|     "show-nutrition-values": "Show Nutrition Values", | ||||
|     "sodium-content": "Sodium", | ||||
| @@ -545,6 +546,8 @@ | ||||
|     "failed-to-add-recipe-to-mealplan": "Failed to add recipe to mealplan", | ||||
|     "failed-to-add-to-list": "Failed to add to list", | ||||
|     "yield": "Yield", | ||||
|     "yields-amount-with-text": "Yields {amount} {text}", | ||||
|     "yield-text": "Yield Text", | ||||
|     "quantity": "Quantity", | ||||
|     "choose-unit": "Choose Unit", | ||||
|     "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.", | ||||
|     "debug": "Debug", | ||||
|     "tree-view": "Tree View", | ||||
|     "recipe-servings": "Recipe Servings", | ||||
|     "recipe-yield": "Recipe Yield", | ||||
|     "recipe-yield-text": "Recipe Yield Text", | ||||
|     "unit": "Unit", | ||||
|     "upload-image": "Upload image", | ||||
|     "screen-awake": "Keep Screen Awake", | ||||
|   | ||||
| @@ -126,6 +126,8 @@ export interface RecipeSummary { | ||||
|   name?: string | null; | ||||
|   slug?: string; | ||||
|   image?: unknown; | ||||
|   recipeServings?: number; | ||||
|   recipeYieldQuantity?: number; | ||||
|   recipeYield?: string | null; | ||||
|   totalTime?: string | null; | ||||
|   prepTime?: string | null; | ||||
|   | ||||
| @@ -62,6 +62,8 @@ export interface RecipeSummary { | ||||
|   name?: string | null; | ||||
|   slug?: string; | ||||
|   image?: unknown; | ||||
|   recipeServings?: number; | ||||
|   recipeYieldQuantity?: number; | ||||
|   recipeYield?: string | null; | ||||
|   totalTime?: string | null; | ||||
|   prepTime?: string | null; | ||||
|   | ||||
| @@ -87,6 +87,8 @@ export interface RecipeSummary { | ||||
|   name?: string | null; | ||||
|   slug?: string; | ||||
|   image?: unknown; | ||||
|   recipeServings?: number; | ||||
|   recipeYieldQuantity?: number; | ||||
|   recipeYield?: string | null; | ||||
|   totalTime?: string | null; | ||||
|   prepTime?: string | null; | ||||
|   | ||||
| @@ -230,6 +230,8 @@ export interface Recipe { | ||||
|   name?: string | null; | ||||
|   slug?: string; | ||||
|   image?: unknown; | ||||
|   recipeServings?: number; | ||||
|   recipeYieldQuantity?: number; | ||||
|   recipeYield?: string | null; | ||||
|   totalTime?: string | null; | ||||
|   prepTime?: string | null; | ||||
| @@ -307,6 +309,8 @@ export interface RecipeSummary { | ||||
|   name?: string | null; | ||||
|   slug?: string; | ||||
|   image?: unknown; | ||||
|   recipeServings?: number; | ||||
|   recipeYieldQuantity?: number; | ||||
|   recipeYield?: string | null; | ||||
|   totalTime?: string | null; | ||||
|   prepTime?: string | null; | ||||
|   | ||||
| @@ -218,6 +218,8 @@ export default defineComponent({ | ||||
|       tags: true, | ||||
|       tools: true, | ||||
|       categories: true, | ||||
|       recipeServings: false, | ||||
|       recipeYieldQuantity: false, | ||||
|       recipeYield: false, | ||||
|       dateAdded: false, | ||||
|     }); | ||||
| @@ -228,7 +230,9 @@ export default defineComponent({ | ||||
|       tags: i18n.t("tag.tags"), | ||||
|       categories: i18n.t("recipe.categories"), | ||||
|       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"), | ||||
|     }; | ||||
|  | ||||
|   | ||||
| @@ -89,7 +89,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): | ||||
|     cook_time: 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") | ||||
|     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") | ||||
|     org_url: Mapped[str | None] = mapped_column(sa.String) | ||||
|     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 | ||||
|     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") | ||||
|     def validate_name(self, _, name): | ||||
|         assert name != "" | ||||
|   | ||||
| @@ -8,6 +8,14 @@ | ||||
|         "recipe-defaults": { | ||||
|             "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" | ||||
|         }, | ||||
|         "servings-text": { | ||||
|             "makes": "Makes", | ||||
|             "serves": "Serves", | ||||
|             "serving": "Serving", | ||||
|             "servings": "Servings", | ||||
|             "yield": "Yield", | ||||
|             "yields": "Yields" | ||||
|         } | ||||
|     }, | ||||
|     "mealplan": { | ||||
|   | ||||
| @@ -29,3 +29,9 @@ def local_provider(accept_language: str | None = Header(None)) -> Translator: | ||||
|     factory = _load_factory() | ||||
|     accept_language = accept_language or "en-US" | ||||
|     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 functools import cached_property | ||||
| from pathlib import Path | ||||
|  | ||||
| from .json_provider import JsonProvider | ||||
| @@ -10,7 +11,7 @@ class InUseProvider: | ||||
|     locks: int | ||||
|  | ||||
|  | ||||
| @dataclass(slots=True) | ||||
| @dataclass | ||||
| class ProviderFactory: | ||||
|     directory: Path | ||||
|     fallback_locale: str = "en-US" | ||||
| @@ -22,6 +23,10 @@ class ProviderFactory: | ||||
|     def fallback_file(self) -> Path: | ||||
|         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: | ||||
|         filename = self.filename_format.format(locale=locale, format="json") | ||||
|         path = self.directory / filename | ||||
|   | ||||
| @@ -116,7 +116,7 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str: | ||||
|         "prepTime": recipe.prep_time, | ||||
|         "cookTime": recipe.cook_time, | ||||
|         "totalTime": recipe.total_time, | ||||
|         "recipeYield": recipe.recipe_yield, | ||||
|         "recipeYield": recipe.recipe_yield_display, | ||||
|         "recipeIngredient": ingredients, | ||||
|         "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 [], | ||||
|   | ||||
| @@ -91,6 +91,8 @@ class RecipeSummary(MealieModel): | ||||
|     name: str | None = None | ||||
|     slug: Annotated[str, Field(validate_default=True)] = "" | ||||
|     image: Any | None = None | ||||
|     recipe_servings: float = 0 | ||||
|     recipe_yield_quantity: float = 0 | ||||
|     recipe_yield: str | None = None | ||||
|  | ||||
|     total_time: str | None = None | ||||
| @@ -122,6 +124,10 @@ class RecipeSummary(MealieModel): | ||||
|  | ||||
|         return val | ||||
|  | ||||
|     @property | ||||
|     def recipe_yield_display(self) -> str: | ||||
|         return f"{self.recipe_yield_quantity} {self.recipe_yield}".strip() | ||||
|  | ||||
|     @classmethod | ||||
|     def loader_options(cls) -> list[LoaderOption]: | ||||
|         return [ | ||||
|   | ||||
| @@ -92,10 +92,8 @@ class TandoorMigrator(BaseMigrator): | ||||
|             recipe_data.pop("working_time", 0), recipe_data.pop("waiting_time", 0) | ||||
|         ) | ||||
|  | ||||
|         serving_size = recipe_data.pop("servings", 0) | ||||
|         serving_text = recipe_data.pop("servings_text", "") | ||||
|         if serving_size and serving_text: | ||||
|             recipe_data["recipeYield"] = f"{serving_size} {serving_text}" | ||||
|         recipe_data["recipeYieldQuantity"] = recipe_data.pop("servings", 0) | ||||
|         recipe_data["recipeYield"] = recipe_data.pop("servings_text", "") | ||||
|  | ||||
|         try: | ||||
|             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 .._helpers import check_char, move_parens_to_end | ||||
| from ..parser_utils import check_char, move_parens_to_end | ||||
|  | ||||
|  | ||||
| class BruteParsedIngredient(BaseModel): | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import re | ||||
| import unicodedata | ||||
|  | ||||
| from mealie.services.parser_services.parser_utils import convert_vulgar_fractions_to_regular_fractions | ||||
|  | ||||
| replace_abbreviations = { | ||||
|     "cup": " cup ", | ||||
| @@ -29,23 +30,6 @@ def remove_periods(string: str) -> str: | ||||
|     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): | ||||
|     """ | ||||
|     Attempts to wrap or clauses in () | ||||
| @@ -75,7 +59,7 @@ def pre_process_string(string: str) -> str: | ||||
|  | ||||
|     """ | ||||
|     string = string.lower() | ||||
|     string = replace_fraction_unicode(string) | ||||
|     string = convert_vulgar_fractions_to_regular_fractions(string) | ||||
|     string = remove_periods(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 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.services.parser_services.parser_utils import extract_quantity_from_string | ||||
|  | ||||
| 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["slug"] = slugify(recipe_data.get("name", "")) | ||||
|     recipe_data["description"] = clean_string(recipe_data.get("description", "")) | ||||
|  | ||||
|     # Times | ||||
|     recipe_data["prepTime"] = clean_time(recipe_data.get("prepTime"), translator) | ||||
|     recipe_data["performTime"] = clean_time(recipe_data.get("performTime"), 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["recipeYield"] = clean_yield(recipe_data.get("recipeYield")) | ||||
|     recipe_data["recipeIngredient"] = clean_ingredients(recipe_data.get("recipeIngredient", [])) | ||||
|     recipe_data["recipeInstructions"] = clean_instructions(recipe_data.get("recipeInstructions", [])) | ||||
|  | ||||
|     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["notes"] = clean_notes(recipe_data.get("notes")) | ||||
|     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 | ||||
|  | ||||
|  | ||||
| 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. | ||||
|  | ||||
| @@ -333,15 +361,34 @@ def clean_yield(yld: str | list[str] | None) -> str: | ||||
|         - `["4 servings", "4 Pies"]` - returns the last value | ||||
|  | ||||
|     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 | ||||
|     """ | ||||
|     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: | ||||
|         return "" | ||||
|             continue | ||||
|         if not isinstance(yld, str): | ||||
|             yld = str(yld) | ||||
|  | ||||
|     if isinstance(yld, list): | ||||
|         return yld[-1] | ||||
|         qty, txt = extract_quantity_from_string(yld) | ||||
|         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: | ||||
|   | ||||
| @@ -275,22 +275,102 @@ yield_test_cases = ( | ||||
|     CleanerCase( | ||||
|         test_id="empty string", | ||||
|         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( | ||||
|         test_id="list of strings", | ||||
|         input=["Makes 4 Batches", "4 Batches"], | ||||
|         expected="4 Batches", | ||||
|         input=["Serves 2", "4 Batches", "5 Batches"], | ||||
|         expected=(2, 5, "Batches"), | ||||
|     ), | ||||
|     CleanerCase( | ||||
|         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", | ||||
|         expected="Makes 4 Batches", | ||||
|         expected=(4, 0, ""), | ||||
|     ), | ||||
|     CleanerCase( | ||||
|         test_id="empty list", | ||||
|         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