mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -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:
parent
c60c63852b
commit
e24e28ae03
@ -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));
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user