diff --git a/alembic/versions/2023-08-14-19.30.49_1825b5225403_added_recipe_note_to_shopping_list_.py b/alembic/versions/2023-08-14-19.30.49_1825b5225403_added_recipe_note_to_shopping_list_.py new file mode 100644 index 00000000000..b6a6489016b --- /dev/null +++ b/alembic/versions/2023-08-14-19.30.49_1825b5225403_added_recipe_note_to_shopping_list_.py @@ -0,0 +1,28 @@ +"""added recipe note to shopping list recipe ref + +Revision ID: 1825b5225403 +Revises: 04ac51cbe9a4 +Create Date: 2023-08-14 19:30:49.103185 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "1825b5225403" +down_revision = "04ac51cbe9a4" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("shopping_list_item_recipe_reference", sa.Column("recipe_note", sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("shopping_list_item_recipe_reference", "recipe_note") + # ### end Alembic commands ### diff --git a/alembic/versions/2023-08-15-16.25.07_bcfdad6b7355_remove_tool_name_and_slug_unique_.py b/alembic/versions/2023-08-15-16.25.07_bcfdad6b7355_remove_tool_name_and_slug_unique_.py new file mode 100644 index 00000000000..c61e0b98943 --- /dev/null +++ b/alembic/versions/2023-08-15-16.25.07_bcfdad6b7355_remove_tool_name_and_slug_unique_.py @@ -0,0 +1,32 @@ +"""remove tool name and slug unique contraints + +Revision ID: bcfdad6b7355 +Revises: 1825b5225403 +Create Date: 2023-08-15 16:25:07.058929 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "bcfdad6b7355" +down_revision = "1825b5225403" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("ix_tools_name", table_name="tools") + op.create_index(op.f("ix_tools_name"), "tools", ["name"], unique=False) + op.drop_index("ix_tools_slug", table_name="tools") + op.create_index(op.f("ix_tools_slug"), "tools", ["slug"], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_tools_slug"), table_name="tools") + op.create_index("ix_tools_slug", "tools", ["slug"], unique=True) + op.drop_index(op.f("ix_tools_name"), table_name="tools") + op.create_index("ix_tools_name", "tools", ["name"], unique=True) + # ### end Alembic commands ### diff --git a/frontend/assets/audio/kitchen_alarm.mp3 b/frontend/assets/audio/kitchen_alarm.mp3 new file mode 100644 index 00000000000..e8eadc01a70 Binary files /dev/null and b/frontend/assets/audio/kitchen_alarm.mp3 differ diff --git a/frontend/components/Domain/Recipe/RecipeActionMenu.vue b/frontend/components/Domain/Recipe/RecipeActionMenu.vue index 2dc804782d5..2c975cae62b 100644 --- a/frontend/components/Domain/Recipe/RecipeActionMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeActionMenu.vue @@ -42,6 +42,12 @@ + + - + - + - + - - {{ name }} + + {{ name }} @@ -120,7 +120,11 @@ export default defineComponent({ vertical: { type: Boolean, default: false, - } + }, + isFlat: { + type: Boolean, + default: false, + }, }, setup() { const { $auth } = useContext(); @@ -162,4 +166,9 @@ export default defineComponent({ .text-top { align-self: start !important; } + +.flat { + box-shadow: none!important; + background-color: transparent; +} diff --git a/frontend/components/Domain/Recipe/RecipeContextMenu.vue b/frontend/components/Domain/Recipe/RecipeContextMenu.vue index a5016bd753d..9134e893470 100644 --- a/frontend/components/Domain/Recipe/RecipeContextMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeContextMenu.vue @@ -114,7 +114,10 @@ color="secondary" /> - + @@ -146,7 +149,7 @@ :nudge-top="menuTop ? '5' : '0'" allow-overflow close-delay="125" - open-on-hover + :open-on-hover="$vuetify.breakpoint.mdAndUp" content-class="d-print-none" > @@ -191,3 +196,9 @@ export default defineComponent({ }, }); + + diff --git a/frontend/components/Domain/Recipe/RecipeTimerMenu.vue b/frontend/components/Domain/Recipe/RecipeTimerMenu.vue new file mode 100644 index 00000000000..2e36da549e3 --- /dev/null +++ b/frontend/components/Domain/Recipe/RecipeTimerMenu.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue index 0cb6e8dd3c6..ae618021ded 100644 --- a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue +++ b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue @@ -13,7 +13,7 @@ > @@ -26,11 +26,37 @@
@@ -38,14 +64,14 @@ - - - {{ $globals.icons.edit }} - -
+ + + + +
@@ -70,11 +96,13 @@ diff --git a/frontend/components/global/ImageCropper.vue b/frontend/components/global/ImageCropper.vue new file mode 100644 index 00000000000..b36e471b836 --- /dev/null +++ b/frontend/components/global/ImageCropper.vue @@ -0,0 +1,152 @@ + + + diff --git a/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts b/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts new file mode 100644 index 00000000000..3bc8e7996e8 --- /dev/null +++ b/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts @@ -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"); + }) +}); diff --git a/frontend/composables/recipe-page/use-extract-recipe-yield.ts b/frontend/composables/recipe-page/use-extract-recipe-yield.ts new file mode 100644 index 00000000000..53d17b264b9 --- /dev/null +++ b/frontend/composables/recipe-page/use-extract-recipe-yield.ts @@ -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)); + } +} diff --git a/frontend/composables/recipes/index.ts b/frontend/composables/recipes/index.ts index 2d58768320d..4578756fa34 100644 --- a/frontend/composables/recipes/index.ts +++ b/frontend/composables/recipes/index.ts @@ -1,6 +1,6 @@ export { useFraction } from "./use-fraction"; export { useRecipe } from "./use-recipe"; export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes"; -export { parseIngredientText } from "./use-recipe-ingredients"; +export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients"; export { useTools } from "./use-recipe-tools"; export { useRecipeMeta } from "./use-recipe-meta"; diff --git a/frontend/composables/recipes/use-recipe-ingredients.test.ts b/frontend/composables/recipes/use-recipe-ingredients.test.ts new file mode 100644 index 00000000000..c7e6900bea7 --- /dev/null +++ b/frontend/composables/recipes/use-recipe-ingredients.test.ts @@ -0,0 +1,51 @@ +import { describe, test, expect } from "vitest"; +import { parseIngredientText } from "./use-recipe-ingredients"; +import { RecipeIngredient } from "~/lib/api/types/recipe"; + +describe(parseIngredientText.name, () => { + const createRecipeIngredient = (overrides: Partial): RecipeIngredient => ({ + quantity: 1, + food: { + id: "1", + name: "Item 1", + }, + unit: { + id: "1", + name: "cup", + }, + ...overrides, + }); + + test("uses ingredient note if disableAmount: true", () => { + const ingredient = createRecipeIngredient({ note: "foo" }); + + expect(parseIngredientText(ingredient, true)).toEqual("foo"); + }); + + test("adds note section if note present", () => { + const ingredient = createRecipeIngredient({ note: "custom note" }); + + expect(parseIngredientText(ingredient, false)).toContain("custom note"); + }); + + test("ingredient text with fraction", () => { + const ingredient = createRecipeIngredient({ quantity: 1.5, unit: { fraction: true, id: "1", name: "cup" } }); + + expect(parseIngredientText(ingredient, false, 1, true)).contain("1 1").and.to.contain("2"); + }); + + test("ingredient text with fraction no formatting", () => { + const ingredient = createRecipeIngredient({ quantity: 1.5, unit: { fraction: true, id: "1", name: "cup" } }); + const result = parseIngredientText(ingredient, false, 1, false); + + expect(result).not.contain("<"); + expect(result).not.contain(">"); + expect(result).contain("1 1/2"); + }); + + test("sanitizes html", () => { + const ingredient = createRecipeIngredient({ note: "" }); + + expect(parseIngredientText(ingredient, false)).not.toContain("