mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	feat: Improved recipeYield Parsing For Fractions and Decimals (#2507)
* improved recipeYield parsing for fracs/decimals * added fix for edgecase with weird fractions * made typescript happy * lint * extracted yield calculation into composable * fixed some gross edgecases * added tests * made bare return clearer --------- Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
		| @@ -33,6 +33,8 @@ 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 } from "~/composables/recipe-page/use-extract-recipe-yield"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   components: { | ||||
|     RecipeScaleEditButton, | ||||
| @@ -65,29 +67,11 @@ export default defineComponent({ | ||||
|     }); | ||||
|  | ||||
|     const scaledYield = computed(() => { | ||||
|       const regMatchNum = /\d+/; | ||||
|       const yieldString = props.recipe.recipeYield; | ||||
|       const num = yieldString?.match(regMatchNum); | ||||
|  | ||||
|       if (num && num?.length > 0) { | ||||
|         const yieldAsInt = parseInt(num[0]); | ||||
|         return yieldString?.replace(num[0], String(yieldAsInt * scaleValue.value)); | ||||
|       } | ||||
|  | ||||
|       return props.recipe.recipeYield; | ||||
|       return useExtractRecipeYield(props.recipe.recipeYield, scaleValue.value); | ||||
|     }); | ||||
|  | ||||
|     const basicYield = computed(() => { | ||||
|       const regMatchNum = /\d+/; | ||||
|       const yieldString = props.recipe.recipeYield; | ||||
|       const num = yieldString?.match(regMatchNum); | ||||
|  | ||||
|       if (num && num?.length > 0) { | ||||
|         const yieldAsInt = parseInt(num[0]); | ||||
|         return yieldString?.replace(num[0], String(yieldAsInt)); | ||||
|       } | ||||
|  | ||||
|       return props.recipe.recipeYield; | ||||
|       return useExtractRecipeYield(props.recipe.recipeYield, 1); | ||||
|     }); | ||||
|  | ||||
|     return { | ||||
|   | ||||
| @@ -0,0 +1,111 @@ | ||||
| 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"); | ||||
|     }) | ||||
| }); | ||||
							
								
								
									
										132
									
								
								frontend/composables/recipe-page/use-extract-recipe-yield.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								frontend/composables/recipe-page/use-extract-recipe-yield.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,132 @@ | ||||
| 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; | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| 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)); | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user