mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -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 { NoUndefinedField } from "~/lib/api/types/non-generated"; | ||||||
| import { Recipe } from "~/lib/api/types/recipe"; | import { Recipe } from "~/lib/api/types/recipe"; | ||||||
| import { usePageState } from "~/composables/recipe-page/shared-state"; | import { usePageState } from "~/composables/recipe-page/shared-state"; | ||||||
|  | import { useExtractRecipeYield } from "~/composables/recipe-page/use-extract-recipe-yield"; | ||||||
|  |  | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: { |   components: { | ||||||
|     RecipeScaleEditButton, |     RecipeScaleEditButton, | ||||||
| @@ -65,29 +67,11 @@ export default defineComponent({ | |||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     const scaledYield = computed(() => { |     const scaledYield = computed(() => { | ||||||
|       const regMatchNum = /\d+/; |       return useExtractRecipeYield(props.recipe.recipeYield, scaleValue.value); | ||||||
|       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; |  | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     const basicYield = computed(() => { |     const basicYield = computed(() => { | ||||||
|       const regMatchNum = /\d+/; |       return useExtractRecipeYield(props.recipe.recipeYield, 1); | ||||||
|       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 { |     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