From 9e2bd297d964869b173ebc9b034bb04366eba397 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Wed, 23 Oct 2024 16:59:34 +0000 Subject: [PATCH 01/56] added column for recipe yield qty --- ...4631_add_summary_to_recipe_instructions.py | 1 - ..._b1020f328e98_add_recipe_yield_quantity.py | 41 +++++++++++++++++++ mealie/db/models/recipe/recipe.py | 7 +++- 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/2024-10-23-15.50.59_b1020f328e98_add_recipe_yield_quantity.py diff --git a/alembic/versions/2024-10-20-09.47.46_3897397b4631_add_summary_to_recipe_instructions.py b/alembic/versions/2024-10-20-09.47.46_3897397b4631_add_summary_to_recipe_instructions.py index f5b6d73ba00..1175ca6ef9d 100644 --- a/alembic/versions/2024-10-20-09.47.46_3897397b4631_add_summary_to_recipe_instructions.py +++ b/alembic/versions/2024-10-20-09.47.46_3897397b4631_add_summary_to_recipe_instructions.py @@ -8,7 +8,6 @@ import sqlalchemy as sa -import mealie.db.migration_types from alembic import op # revision identifiers, used by Alembic. diff --git a/alembic/versions/2024-10-23-15.50.59_b1020f328e98_add_recipe_yield_quantity.py b/alembic/versions/2024-10-23-15.50.59_b1020f328e98_add_recipe_yield_quantity.py new file mode 100644 index 00000000000..074757a6399 --- /dev/null +++ b/alembic/versions/2024-10-23-15.50.59_b1020f328e98_add_recipe_yield_quantity.py @@ -0,0 +1,41 @@ +"""add recipe yield quantity + +Revision ID: b1020f328e98 +Revises: 3897397b4631 +Create Date: 2024-10-23 15:50:59.888793 + +""" + +import sqlalchemy as sa + +from alembic import op + +# 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 + + +def parse_recipe_yields(): + pass # TODO + + +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) + + # ### 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_yield_quantity")) + batch_op.drop_column("recipe_yield_quantity") + + # ### end Alembic commands ### diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index fc9c142bb9c..cb790376cdd 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -89,7 +89,7 @@ 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) 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 +131,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 +166,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 != "" From 6a4377484252d24b34e7ea94aaa2c9f5773520da Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 31 Oct 2024 13:21:00 +0000 Subject: [PATCH 02/56] updated yield cleaning on recipe import --- .../parser_services/_helpers/string_utils.py | 23 ---- .../services/parser_services/brute/process.py | 75 ++----------- .../parser_services/crfpp/pre_processor.py | 22 +--- .../{_helpers => parser_utils}/__init__.py | 0 .../parser_utils/string_utils.py | 100 ++++++++++++++++++ mealie/services/scraper/cleaner.py | 32 ++++-- 6 files changed, 136 insertions(+), 116 deletions(-) delete mode 100644 mealie/services/parser_services/_helpers/string_utils.py rename mealie/services/parser_services/{_helpers => parser_utils}/__init__.py (100%) create mode 100644 mealie/services/parser_services/parser_utils/string_utils.py diff --git a/mealie/services/parser_services/_helpers/string_utils.py b/mealie/services/parser_services/_helpers/string_utils.py deleted file mode 100644 index 426d540c8e2..00000000000 --- a/mealie/services/parser_services/_helpers/string_utils.py +++ /dev/null @@ -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) diff --git a/mealie/services/parser_services/brute/process.py b/mealie/services/parser_services/brute/process.py index 446a6f54a59..917ef9821a7 100644 --- a/mealie/services/parser_services/brute/process.py +++ b/mealie/services/parser_services/brute/process.py @@ -1,9 +1,6 @@ -import string -import unicodedata - from pydantic import BaseModel, ConfigDict -from .._helpers import check_char, move_parens_to_end +from ..parser_utils import extract_quantity_from_string, move_parens_to_end class BruteParsedIngredient(BaseModel): @@ -14,74 +11,15 @@ class BruteParsedIngredient(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) -def parse_fraction(x): - if len(x) == 1 and "fraction" in unicodedata.decomposition(x): - frac_split = unicodedata.decomposition(x[-1:]).split() - return float((frac_split[1]).replace("003", "")) / float((frac_split[3]).replace("003", "")) - else: - frac_split = x.split("/") - if len(frac_split) != 2: - raise ValueError - try: - return int(frac_split[0]) / int(frac_split[1]) - except ZeroDivisionError as e: - raise ValueError from e - - def parse_amount(ing_str) -> tuple[float, str, str]: - def keep_looping(ing_str, end) -> bool: - """ - Checks if: - 1. the end of the string is reached - 2. or if the next character is a digit - 3. or if the next character looks like an number (e.g. 1/2, 1.3, 1,500) - """ - if end >= len(ing_str): - return False - - if ing_str[end] in string.digits: - return True - - if check_char(ing_str[end], ".", ",", "/") and end + 1 < len(ing_str) and ing_str[end + 1] in string.digits: - return True - - return False - - amount = 0.0 - unit = "" - note = "" - - did_check_frac = False - end = 0 - - while keep_looping(ing_str, end): - end += 1 + amount, unit = extract_quantity_from_string(ing_str) - if end > 0: - if "/" in ing_str[:end]: - amount = parse_fraction(ing_str[:end]) - else: - amount = float(ing_str[:end].replace(",", ".")) - else: - amount = parse_fraction(ing_str[0]) - end += 1 - did_check_frac = True - if end < len(ing_str): - if did_check_frac: - unit = ing_str[end:] - else: - try: - amount += parse_fraction(ing_str[end]) - - unit_end = end + 1 - unit = ing_str[unit_end:] - except ValueError: - unit = ing_str[end:] - - # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3 + # I don't know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3 if unit.startswith("(") or unit.startswith("-"): unit = "" note = ing_str + else: + note = "" return amount, unit, note @@ -160,7 +98,8 @@ def parse(ing_str, parser) -> BruteParsedIngredient: # probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except # noqa: E501 raise ValueError # try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½' - amount += parse_fraction(tokens[1]) + adtl_amount, _ = extract_quantity_from_string(tokens[1]) + amount += adtl_amount # assume that units can't end with a comma if len(tokens) > 3 and not tokens[2].endswith(","): # try to use third argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501 diff --git a/mealie/services/parser_services/crfpp/pre_processor.py b/mealie/services/parser_services/crfpp/pre_processor.py index d008acb49e1..aea0262a041 100644 --- a/mealie/services/parser_services/crfpp/pre_processor.py +++ b/mealie/services/parser_services/crfpp/pre_processor.py @@ -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"(? 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) diff --git a/mealie/services/parser_services/_helpers/__init__.py b/mealie/services/parser_services/parser_utils/__init__.py similarity index 100% rename from mealie/services/parser_services/_helpers/__init__.py rename to mealie/services/parser_services/parser_utils/__init__.py diff --git a/mealie/services/parser_services/parser_utils/string_utils.py b/mealie/services/parser_services/parser_utils/string_utils.py new file mode 100644 index 00000000000..c5e405eb360 --- /dev/null +++ b/mealie/services/parser_services/parser_utils/string_utils.py @@ -0,0 +1,100 @@ +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 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(): + text = text.replace(vulgar_fraction, regular_fraction) + + 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+)?") + + # Check for a mixed fraction (e.g. "1 1/2") + match = mixed_fraction_pattern.match(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.end() :].strip() + + remaining_str = remaining_str.strip() + return quantity, remaining_str + + # Check for a fraction (e.g. "1/2") + match = fraction_pattern.match(source_str) + if match: + numerator = int(match.group(1)) + denominator = int(match.group(2)) + quantity = float(Fraction(numerator, denominator)) + remaining_str = source_str[match.end() :].strip() + + remaining_str = remaining_str.strip() + return quantity, remaining_str + + # Check for a number (integer or float) + match = number_pattern.match(source_str) + if match: + quantity = float(match.group()) + remaining_str = source_str[match.end() :].strip() + + remaining_str = remaining_str.strip() + return quantity, remaining_str + + # If no match, return 0 and the original string + return 0, source_str diff --git a/mealie/services/scraper/cleaner.py b/mealie/services/scraper/cleaner.py index d685c54d861..92678421f9d 100644 --- a/mealie/services/scraper/cleaner.py +++ b/mealie/services/scraper/cleaner.py @@ -11,6 +11,7 @@ from mealie.core.root_logger import get_logger from mealie.lang.providers import Translator +from mealie.services.parser_services.parser_utils import extract_quantity_from_string logger = get_logger("recipe-scraper") @@ -43,18 +44,20 @@ def clean(recipe_data: dict, translator: Translator, url=None) -> dict: Returns: dict: cleaned recipe dictionary """ + 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["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")) @@ -316,7 +319,7 @@ def clean_notes(notes: typing.Any) -> list[dict] | None: return parsed_notes -def clean_yield(yld: str | list[str] | None) -> str: +def clean_yield(yld: str | list[str] | None) -> tuple[float, str]: """ yield_amount attemps to parse out the yield amount from a recipe. @@ -325,15 +328,19 @@ def clean_yield(yld: str | list[str] | None) -> str: - `["4 servings", "4 Pies"]` - returns the last value Returns: + float: The yield quantity, if it can be parsed else 0 str: The yield amount, if it can be parsed else an empty string """ if not yld: - return "" + return 0, "" if isinstance(yld, list): - return yld[-1] + yld = yld[-1] + + if not isinstance(yld, str): + yld = str(yld) - return yld + return parse_yield(yld) def clean_time(time_entry: str | timedelta | None, translator: Translator) -> None | str: @@ -409,6 +416,19 @@ def parse_duration(iso_duration: str) -> timedelta: return timedelta(**times) +def parse_yield(yield_str: str | None) -> tuple[float, str]: + """ + Attempts to extract the yield quantity from a string. If no quantity is found, sets quantity to 0. + + Returns the yield quantity and the rest of the string. + """ + + if not yield_str: + return 0, "" + + return extract_quantity_from_string(yield_str) + + def pretty_print_timedelta(t: timedelta, translator: Translator, max_components=None, max_decimal_places=2): """ Print a pretty string for a timedelta. From bee731cab97a310a07398c25cc3a68cd11876670 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 31 Oct 2024 13:30:55 +0000 Subject: [PATCH 03/56] parse all recipe yields on migration --- ..._b1020f328e98_add_recipe_yield_quantity.py | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/alembic/versions/2024-10-23-15.50.59_b1020f328e98_add_recipe_yield_quantity.py b/alembic/versions/2024-10-23-15.50.59_b1020f328e98_add_recipe_yield_quantity.py index 074757a6399..1ce48717439 100644 --- a/alembic/versions/2024-10-23-15.50.59_b1020f328e98_add_recipe_yield_quantity.py +++ b/alembic/versions/2024-10-23-15.50.59_b1020f328e98_add_recipe_yield_quantity.py @@ -7,8 +7,11 @@ """ 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" @@ -17,14 +20,36 @@ 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) + + def parse_recipe_yields(): - pass # TODO + bind = op.get_bind() + session = orm.Session(bind=bind) + + for recipe in session.query(RecipeModel).all(): + try: + recipe.recipe_yield_quantity, recipe.recipe_yield = clean_yield(recipe.recipe_yield) + except Exception: + 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.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) # ### end Alembic commands ### From 14d61b2ba5ac39e15ecc24e70babcdfa22d8a518 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 31 Oct 2024 13:42:12 +0000 Subject: [PATCH 04/56] added yield qty to schema --- mealie/schema/recipe/recipe.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index a328614d5c3..1a6a542fea7 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -91,6 +91,7 @@ class RecipeSummary(MealieModel): name: str | None = None slug: Annotated[str, Field(validate_default=True)] = "" image: Any | None = None + recipe_yield_quantity: float = 0 recipe_yield: str | None = None total_time: str | None = None From 9df445b687ba359d532e4c4c9a03a5ba75ce7573 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 31 Oct 2024 14:16:56 +0000 Subject: [PATCH 05/56] dev:generate --- frontend/lib/api/types/admin.ts | 1 + frontend/lib/api/types/cookbook.ts | 1 + frontend/lib/api/types/meal-plan.ts | 1 + frontend/lib/api/types/recipe.ts | 2 ++ 4 files changed, 5 insertions(+) diff --git a/frontend/lib/api/types/admin.ts b/frontend/lib/api/types/admin.ts index 7b40f6a849f..a942e9f4e66 100644 --- a/frontend/lib/api/types/admin.ts +++ b/frontend/lib/api/types/admin.ts @@ -126,6 +126,7 @@ export interface RecipeSummary { name?: string | null; slug?: string; image?: unknown; + recipeYieldQuantity?: number; recipeYield?: string | null; totalTime?: string | null; prepTime?: string | null; diff --git a/frontend/lib/api/types/cookbook.ts b/frontend/lib/api/types/cookbook.ts index e43e48bcbf9..55adbaf376c 100644 --- a/frontend/lib/api/types/cookbook.ts +++ b/frontend/lib/api/types/cookbook.ts @@ -62,6 +62,7 @@ export interface RecipeSummary { name?: string | null; slug?: string; image?: unknown; + recipeYieldQuantity?: number; recipeYield?: string | null; totalTime?: string | null; prepTime?: string | null; diff --git a/frontend/lib/api/types/meal-plan.ts b/frontend/lib/api/types/meal-plan.ts index ae033c1f45b..54a189286b0 100644 --- a/frontend/lib/api/types/meal-plan.ts +++ b/frontend/lib/api/types/meal-plan.ts @@ -87,6 +87,7 @@ export interface RecipeSummary { name?: string | null; slug?: string; image?: unknown; + recipeYieldQuantity?: number; recipeYield?: string | null; totalTime?: string | null; prepTime?: string | null; diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts index 8973fd70f05..e7b62090c49 100644 --- a/frontend/lib/api/types/recipe.ts +++ b/frontend/lib/api/types/recipe.ts @@ -230,6 +230,7 @@ export interface Recipe { name?: string | null; slug?: string; image?: unknown; + recipeYieldQuantity?: number; recipeYield?: string | null; totalTime?: string | null; prepTime?: string | null; @@ -306,6 +307,7 @@ export interface RecipeSummary { name?: string | null; slug?: string; image?: unknown; + recipeYieldQuantity?: number; recipeYield?: string | null; totalTime?: string | null; prepTime?: string | null; From b9c6ab6a36492c24e6ffb2e798fc9df4f3ad8054 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 31 Oct 2024 14:42:49 +0000 Subject: [PATCH 06/56] updated data tables --- frontend/components/Domain/Recipe/RecipeDataTable.vue | 5 +++++ frontend/lang/messages/en-US.json | 2 ++ frontend/pages/group/data/recipes.vue | 2 ++ 3 files changed, 9 insertions(+) diff --git a/frontend/components/Domain/Recipe/RecipeDataTable.vue b/frontend/components/Domain/Recipe/RecipeDataTable.vue index a7098466f21..ec42be088c2 100644 --- a/frontend/components/Domain/Recipe/RecipeDataTable.vue +++ b/frontend/components/Domain/Recipe/RecipeDataTable.vue @@ -63,6 +63,7 @@ interface ShowHeaders { tags: boolean; categories: boolean; tools: boolean; + recipeYieldQuantity: boolean; recipeYield: boolean; dateAdded: boolean; } @@ -93,6 +94,7 @@ export default defineComponent({ owner: false, tags: true, categories: true, + recipeYieldQuantity: true, recipeYield: true, dateAdded: true, }; @@ -127,6 +129,9 @@ export default defineComponent({ if (props.showHeaders.tools) { hdrs.push({ text: i18n.t("tool.tools"), value: "tools" }); } + if (props.showHeaders.recipeYieldQuantity) { + hdrs.push({ text: i18n.t("recipe.yield-quantity"), value: "recipeYieldQuantity" }); + } if (props.showHeaders.recipeYield) { hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYield" }); } diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index 0166401c7e5..c01396eb6b1 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -545,6 +545,7 @@ "failed-to-add-recipe-to-mealplan": "Failed to add recipe to mealplan", "failed-to-add-to-list": "Failed to add to list", "yield": "Yield", + "yield-quantity": "Yield Quantity", "quantity": "Quantity", "choose-unit": "Choose Unit", "press-enter-to-create": "Press Enter to Create", @@ -641,6 +642,7 @@ "debug": "Debug", "tree-view": "Tree View", "recipe-yield": "Recipe Yield", + "recipe-yield-quantity": "Recipe Yield Quantity", "unit": "Unit", "upload-image": "Upload image", "screen-awake": "Keep Screen Awake", diff --git a/frontend/pages/group/data/recipes.vue b/frontend/pages/group/data/recipes.vue index 94d2f245b8d..e05314318cb 100644 --- a/frontend/pages/group/data/recipes.vue +++ b/frontend/pages/group/data/recipes.vue @@ -218,6 +218,7 @@ export default defineComponent({ tags: true, tools: true, categories: true, + recipeYieldQuantity: false, recipeYield: false, dateAdded: false, }); @@ -228,6 +229,7 @@ export default defineComponent({ tags: i18n.t("tag.tags"), categories: i18n.t("recipe.categories"), tools: i18n.t("tool.tools"), + recipeYieldQuantity: i18n.t("recipe.recipe-yield-quantity"), recipeYield: i18n.t("recipe.recipe-yield"), dateAdded: i18n.t("general.date-added"), }; From f5e3ffcf81a9277988bc2bc405be73386490d217 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 31 Oct 2024 14:43:08 +0000 Subject: [PATCH 07/56] updated spa meta --- mealie/routes/spa/__init__.py | 2 +- mealie/schema/recipe/recipe.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/mealie/routes/spa/__init__.py b/mealie/routes/spa/__init__.py index d4c21e4c050..824764b6e42 100644 --- a/mealie/routes/spa/__init__.py +++ b/mealie/routes/spa/__init__.py @@ -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 [], diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index 1a6a542fea7..fcf28a460f8 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -123,6 +123,13 @@ def clean_strings(val: Any): return val + @property + def recipe_yield_display(self) -> str: + if self.recipe_yield_quantity and self.recipe_yield: + return f"{self.recipe_yield_quantity} {self.recipe_yield}" + else: + return str(self.recipe_yield_quantity or self.recipe_yield or "") + @classmethod def loader_options(cls) -> list[LoaderOption]: return [ From 08d9891cd52996aeb3d65ce4d0028f390d257a20 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 31 Oct 2024 15:07:37 +0000 Subject: [PATCH 08/56] updated tandoor migration --- mealie/services/migrations/tandoor.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mealie/services/migrations/tandoor.py b/mealie/services/migrations/tandoor.py index 7806e55ad84..ba89792452f 100644 --- a/mealie/services/migrations/tandoor.py +++ b/mealie/services/migrations/tandoor.py @@ -92,10 +92,8 @@ def _process_recipe_document(self, source_dir: Path, recipe_data: dict) -> dict: 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.*")) From 26b4d2958528b580bf07f1525bfcde0059f2ee23 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 31 Oct 2024 18:08:40 +0000 Subject: [PATCH 09/56] Added new yield composable --- .../composables/recipes/use-recipe-yield.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 frontend/composables/recipes/use-recipe-yield.ts diff --git a/frontend/composables/recipes/use-recipe-yield.ts b/frontend/composables/recipes/use-recipe-yield.ts new file mode 100644 index 00000000000..51cf6d0986a --- /dev/null +++ b/frontend/composables/recipes/use-recipe-yield.ts @@ -0,0 +1,48 @@ +import DOMPurify from "isomorphic-dompurify"; +import { useFraction } from "~/composables/recipes"; + +function sanitizeHTML(rawHtml: string) { + return DOMPurify.sanitize(rawHtml, { + USE_PROFILES: { html: true }, + ALLOWED_TAGS: ["b", "q", "i", "strong", "sup", "span"], + }); +} + +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 += `${fraction[1]}${fraction[2]}`; + } + + return valString.trim(); +} + +export function useRecipeYield(recipeYieldQuantity: number, recipeYield: string, scale: number = 1) { + const yieldQuantity = (recipeYieldQuantity || 0) * scale; + const yieldString = recipeYield || ""; + const yieldQuantityDisplay = yieldQuantity ? formatQuantity(yieldQuantity) : ""; + + const yieldDisplay = sanitizeHTML( + yieldQuantityDisplay + ? `${yieldQuantityDisplay} ${yieldString}`.trim() + : yieldString + ); + + return { + yieldQuantity, + yieldString, + yieldDisplay, + }; +} From da160f54e78678bed6b1b5fbe42e0f1144192433 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 31 Oct 2024 18:09:03 +0000 Subject: [PATCH 10/56] replaced print view yield --- frontend/components/Domain/Recipe/RecipePrintView.vue | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/components/Domain/Recipe/RecipePrintView.vue b/frontend/components/Domain/Recipe/RecipePrintView.vue index c3fcb47a236..efa089e8441 100644 --- a/frontend/components/Domain/Recipe/RecipePrintView.vue +++ b/frontend/components/Domain/Recipe/RecipePrintView.vue @@ -31,7 +31,7 @@
{{ $t("recipe.ingredients") }}
- +
{ + const { yieldDisplay } = useRecipeYield(props.recipe.recipeYieldQuantity, props.recipe.recipeYield, props.scale); + return yieldDisplay; + }); const recipeImageUrl = computed(() => { return recipeImage(props.recipe.id, props.recipe.image, imageKey.value); @@ -258,6 +261,7 @@ export default defineComponent({ parseIngredientText, preferences, recipeImageUrl, + yieldDisplay, ingredientSections, instructionSections, }; From 9942b7bf27126bce4f83e32d4a455fecae3eed1f Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 31 Oct 2024 18:37:16 +0000 Subject: [PATCH 11/56] replace print view yield --- .../RecipePage/RecipePageParts/RecipePageScale.vue | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue index 46d56f7ce9d..48739edf6cf 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue @@ -5,9 +5,8 @@ @@ -33,7 +32,6 @@ 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: { @@ -66,17 +64,8 @@ export default defineComponent({ }, }); - const scaledYield = computed(() => { - return useExtractRecipeYield(props.recipe.recipeYield, scaleValue.value); - }); - - const match = findMatch(props.recipe.recipeYield); - const basicYieldNum = ref(match ? match[1] : null); - return { scaleValue, - scaledYield, - basicYieldNum, isEditMode, }; }, From 4e3eca63b7ad20c4631b3c3c3b9230b63fe91a87 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 31 Oct 2024 18:37:56 +0000 Subject: [PATCH 12/56] round output --- frontend/composables/recipes/use-recipe-yield.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/composables/recipes/use-recipe-yield.ts b/frontend/composables/recipes/use-recipe-yield.ts index 51cf6d0986a..701475d3930 100644 --- a/frontend/composables/recipes/use-recipe-yield.ts +++ b/frontend/composables/recipes/use-recipe-yield.ts @@ -30,7 +30,7 @@ function formatQuantity(val: number): string { } export function useRecipeYield(recipeYieldQuantity: number, recipeYield: string, scale: number = 1) { - const yieldQuantity = (recipeYieldQuantity || 0) * scale; + const yieldQuantity = Number(((recipeYieldQuantity || 0) * scale).toFixed(3)); const yieldString = recipeYield || ""; const yieldQuantityDisplay = yieldQuantity ? formatQuantity(yieldQuantity) : ""; From f97657105feeaa712f5bf8f0cb9629ecd0b267c1 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 31 Oct 2024 18:38:03 +0000 Subject: [PATCH 13/56] simplify prop --- mealie/schema/recipe/recipe.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index fcf28a460f8..439155f804c 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -125,10 +125,7 @@ def clean_strings(val: Any): @property def recipe_yield_display(self) -> str: - if self.recipe_yield_quantity and self.recipe_yield: - return f"{self.recipe_yield_quantity} {self.recipe_yield}" - else: - return str(self.recipe_yield_quantity or self.recipe_yield or "") + return f"{self.recipe_yield_quantity} {self.recipe_yield}".strip() @classmethod def loader_options(cls) -> list[LoaderOption]: From 09a1b86a1b14da084efc9f8e93c88b24a7e2f2b5 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 31 Oct 2024 18:39:04 +0000 Subject: [PATCH 14/56] replace scale editor logic --- .../Domain/Recipe/RecipeScaleEditButton.vue | 79 +++++++++++-------- 1 file changed, 47 insertions(+), 32 deletions(-) diff --git a/frontend/components/Domain/Recipe/RecipeScaleEditButton.vue b/frontend/components/Domain/Recipe/RecipeScaleEditButton.vue index b34c4932420..92bf5636039 100644 --- a/frontend/components/Domain/Recipe/RecipeScaleEditButton.vue +++ b/frontend/components/Domain/Recipe/RecipeScaleEditButton.vue @@ -5,12 +5,9 @@ @@ -20,7 +17,7 @@
- + " + ); + expect(yieldQuantity).toStrictEqual(.5); + expect(yieldString).toStrictEqual(""); + expect(yieldDisplay).toStrictEqual(asFrac(1, 2)); + }); +}); From 1ca4b2c5ec4d5498b179eac177e77679569a8bee Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Fri, 1 Nov 2024 15:28:47 +0000 Subject: [PATCH 19/56] remove unused yield extract --- .../use-extract-recipe-yield.test.ts | 111 --------------- .../recipe-page/use-extract-recipe-yield.ts | 132 ------------------ 2 files changed, 243 deletions(-) delete mode 100644 frontend/composables/recipe-page/use-extract-recipe-yield.test.ts delete mode 100644 frontend/composables/recipe-page/use-extract-recipe-yield.ts diff --git a/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts b/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts deleted file mode 100644 index 3bc8e7996e8..00000000000 --- a/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts +++ /dev/null @@ -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"); - }) -}); diff --git a/frontend/composables/recipe-page/use-extract-recipe-yield.ts b/frontend/composables/recipe-page/use-extract-recipe-yield.ts deleted file mode 100644 index bec7d658563..00000000000 --- a/frontend/composables/recipe-page/use-extract-recipe-yield.ts +++ /dev/null @@ -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)); - } -} From ae614cafe869add9ac79ab31b2165e3a7b871851 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Fri, 1 Nov 2024 15:49:47 +0000 Subject: [PATCH 20/56] catch zero div errors --- .../parser_utils/string_utils.py | 66 ++++++++++--------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/mealie/services/parser_services/parser_utils/string_utils.py b/mealie/services/parser_services/parser_utils/string_utils.py index c5e405eb360..711bac1b4d8 100644 --- a/mealie/services/parser_services/parser_utils/string_utils.py +++ b/mealie/services/parser_services/parser_utils/string_utils.py @@ -64,37 +64,41 @@ def extract_quantity_from_string(source_str: str) -> tuple[float, str]: fraction_pattern = re.compile(r"^(\d+)/(\d+)") number_pattern = re.compile(r"^\d+(\.\d+)?") - # Check for a mixed fraction (e.g. "1 1/2") - match = mixed_fraction_pattern.match(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.end() :].strip() - - remaining_str = remaining_str.strip() - return quantity, remaining_str - - # Check for a fraction (e.g. "1/2") - match = fraction_pattern.match(source_str) - if match: - numerator = int(match.group(1)) - denominator = int(match.group(2)) - quantity = float(Fraction(numerator, denominator)) - remaining_str = source_str[match.end() :].strip() - - remaining_str = remaining_str.strip() - return quantity, remaining_str - - # Check for a number (integer or float) - match = number_pattern.match(source_str) - if match: - quantity = float(match.group()) - remaining_str = source_str[match.end() :].strip() - - remaining_str = remaining_str.strip() - return quantity, remaining_str + try: + # Check for a mixed fraction (e.g. "1 1/2") + match = mixed_fraction_pattern.match(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.end() :].strip() + + remaining_str = remaining_str.strip() + return quantity, remaining_str + + # Check for a fraction (e.g. "1/2") + match = fraction_pattern.match(source_str) + if match: + numerator = int(match.group(1)) + denominator = int(match.group(2)) + quantity = float(Fraction(numerator, denominator)) + remaining_str = source_str[match.end() :].strip() + + remaining_str = remaining_str.strip() + return quantity, remaining_str + + # Check for a number (integer or float) + match = number_pattern.match(source_str) + if match: + quantity = float(match.group()) + remaining_str = source_str[match.end() :].strip() + + 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 From 7d011a25089637a71a31ebdb87f5738befc08d27 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Fri, 1 Nov 2024 15:53:38 +0000 Subject: [PATCH 21/56] fix mixed vulgar fractions --- mealie/services/parser_services/parser_utils/string_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mealie/services/parser_services/parser_utils/string_utils.py b/mealie/services/parser_services/parser_utils/string_utils.py index 711bac1b4d8..c9c517be0bd 100644 --- a/mealie/services/parser_services/parser_utils/string_utils.py +++ b/mealie/services/parser_services/parser_utils/string_utils.py @@ -42,7 +42,9 @@ def convert_vulgar_fractions_to_regular_fractions(text: str) -> str: } for vulgar_fraction, regular_fraction in vulgar_fractions.items(): - text = text.replace(vulgar_fraction, regular_fraction) + # 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 From 8f45ca8bfd1fb176e4d2107c754ad70d3b81edf8 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Fri, 1 Nov 2024 15:53:47 +0000 Subject: [PATCH 22/56] add/update yield cleaner tests --- .../scraper_tests/test_cleaner_parts.py | 73 ++++++++++++++++++- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py b/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py index 1c308ac912f..20f129c6e1e 100644 --- a/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py +++ b/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py @@ -275,22 +275,87 @@ def test_cleaner_clean_ingredients(ingredients: CleanerCase): CleanerCase( test_id="empty string", input="", - expected="", + expected=(0, ""), + ), + CleanerCase( + test_id="regular string", + input="4 Batches", + expected=(4, "Batches"), + ), + CleanerCase( + test_id="regular string with whitespace", + input="4 Batches ", + expected=(4, "Batches"), ), CleanerCase( test_id="list of strings", input=["Makes 4 Batches", "4 Batches"], - expected="4 Batches", + expected=(4, "Batches"), ), CleanerCase( test_id="basic string", input="Makes 4 Batches", - expected="Makes 4 Batches", + expected=(0, "Makes 4 Batches"), ), CleanerCase( test_id="empty list", input=[], - expected="", + expected=(0, ""), + ), + CleanerCase( + test_id="basic fraction", + input="1/2 Servings", + expected=(0.5, "Servings"), + ), + CleanerCase( + test_id="mixed fraction", + input="1 1/2 Servings", + expected=(1.5, "Servings"), + ), + CleanerCase( + test_id="improper fraction", + input="11/2 Servings", + expected=(5.5, "Servings"), + ), + CleanerCase( + test_id="vulgar fraction", + input="¾ Servings", + expected=(0.75, "Servings"), + ), + CleanerCase( + test_id="mixed vulgar fraction", + input="2¾ Servings", + expected=(2.75, "Servings"), + ), + CleanerCase( + test_id="mixed vulgar fraction with space", + input="2 ¾ Servings", + expected=(2.75, "Servings"), + ), + CleanerCase( + test_id="basic decimal", + input="0.5 Servings", + expected=(0.5, "Servings"), + ), + CleanerCase( + test_id="text with numbers", + input="6 Servings or 10 Servings", + expected=(6, "Servings or 10 Servings"), + ), + CleanerCase( + test_id="no qty", + input="A Lot of Servings", + expected=(0, "A Lot of Servings"), + ), + CleanerCase( + test_id="invalid qty", + input="1/0 Servings", + expected=(0, "1/0 Servings"), + ), + CleanerCase( + test_id="int as float", + input="3.0 Servings", + expected=(3, "Servings"), ), ) From a7c43f642d548266bd7f0d194d562c03d8f45e5d Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Fri, 1 Nov 2024 16:07:25 +0000 Subject: [PATCH 23/56] fix brute parser --- mealie/services/parser_services/brute/process.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mealie/services/parser_services/brute/process.py b/mealie/services/parser_services/brute/process.py index 917ef9821a7..21554aba2e7 100644 --- a/mealie/services/parser_services/brute/process.py +++ b/mealie/services/parser_services/brute/process.py @@ -99,6 +99,9 @@ def parse(ing_str, parser) -> BruteParsedIngredient: raise ValueError # try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½' adtl_amount, _ = extract_quantity_from_string(tokens[1]) + if not adtl_amount: + raise ValueError + amount += adtl_amount # assume that units can't end with a comma if len(tokens) > 3 and not tokens[2].endswith(","): From 791a2c8548ca0f0e8e64a7e93cc42db00ce00b69 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Fri, 1 Nov 2024 16:53:03 +0000 Subject: [PATCH 24/56] lint --- .../Recipe/RecipePage/RecipePageParts/RecipePageScale.vue | 2 +- .../RecipePage/RecipePageParts/RecipePageTitleContent.vue | 6 ++---- frontend/composables/recipes/use-recipe-yield.test.ts | 2 +- frontend/composables/recipes/use-recipe-yield.ts | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue index 48739edf6cf..cb0e330d872 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue @@ -26,7 +26,7 @@ diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index 8477eb15ee3..8c8b78e8789 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -545,6 +545,7 @@ "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", From 252352e28ea874eec18e251f32cb5e9798a7f7b2 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Fri, 8 Nov 2024 17:30:59 +0000 Subject: [PATCH 34/56] changed scale to use servings rather than yield --- .../RecipePage/RecipePageParts/RecipePageScale.vue | 3 +-- .../Domain/Recipe/RecipeScaleEditButton.vue | 14 +++++--------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue index cb0e330d872..aa68ef02224 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue @@ -5,8 +5,7 @@ diff --git a/frontend/components/Domain/Recipe/RecipeScaleEditButton.vue b/frontend/components/Domain/Recipe/RecipeScaleEditButton.vue index 38c14b7902c..7c04279a83a 100644 --- a/frontend/components/Domain/Recipe/RecipeScaleEditButton.vue +++ b/frontend/components/Domain/Recipe/RecipeScaleEditButton.vue @@ -67,14 +67,10 @@ export default defineComponent({ type: Number, required: true, }, - recipeYieldQuantity: { + recipeServings: { type: Number, default: 0, }, - recipeYield: { - type: String, - default: "", - }, editScale: { type: Boolean, default: false, @@ -82,7 +78,7 @@ export default defineComponent({ }, setup(props, { emit }) { const menu = ref(false); - const canEditScale = computed(() => props.editScale && props.recipeYieldQuantity > 0); + const canEditScale = computed(() => props.editScale && props.recipeServings > 0); const scale = computed({ get: () => props.value, @@ -97,15 +93,15 @@ export default defineComponent({ return; } - if (props.recipeYieldQuantity <= 0) { + if (props.recipeServings <= 0) { scale.value = 1; } else { - scale.value = newYield / props.recipeYieldQuantity; + scale.value = newYield / props.recipeServings; } } const recipeYield = computed(() => { - return useRecipeYield(props.recipeYieldQuantity, props.recipeYield, scale.value); + return useRecipeYield(props.recipeServings, "Servings", scale.value); }); const yieldDisplay = computed(() => recipeYield.value.yieldDisplay); const yieldQuantity = computed(() => recipeYield.value.yieldQuantity); From 4bd4d8557c5e641573fc4db06d11507e9c7f1cde Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Fri, 8 Nov 2024 18:21:38 +0000 Subject: [PATCH 35/56] refactor yield to generic amount --- .../Domain/Recipe/RecipePrintView.vue | 29 +++++++++++++++---- .../Domain/Recipe/RecipeScaleEditButton.vue | 23 +++++++++------ .../components/Domain/Recipe/RecipeYield.vue | 10 +++---- ...e-recipe-yield.ts => use-scaled-amount.ts} | 16 ++++------ frontend/lang/messages/en-US.json | 1 + 5 files changed, 48 insertions(+), 31 deletions(-) rename frontend/composables/recipes/{use-recipe-yield.ts => use-scaled-amount.ts} (60%) diff --git a/frontend/components/Domain/Recipe/RecipePrintView.vue b/frontend/components/Domain/Recipe/RecipePrintView.vue index efa089e8441..41e27a5063a 100644 --- a/frontend/components/Domain/Recipe/RecipePrintView.vue +++ b/frontend/components/Domain/Recipe/RecipePrintView.vue @@ -31,7 +31,7 @@
{{ $t("recipe.ingredients") }}
- +
" - ); - expect(yieldQuantity).toStrictEqual(.5); - expect(yieldString).toStrictEqual(""); - expect(yieldDisplay).toStrictEqual(asFrac(1, 2)); - }); -}); diff --git a/frontend/composables/recipes/use-scaled-amount.test.ts b/frontend/composables/recipes/use-scaled-amount.test.ts new file mode 100644 index 00000000000..f3f0cdae52a --- /dev/null +++ b/frontend/composables/recipes/use-scaled-amount.test.ts @@ -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 `${numerator}${denominator}`; + } + + 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)}`); + }); +}); From 9c86bb2f2f985fbc2482e233b073abef8a895c3b Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Fri, 8 Nov 2024 18:46:46 +0000 Subject: [PATCH 37/56] disable scale if empty --- frontend/components/Domain/Recipe/RecipeScaleEditButton.vue | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/components/Domain/Recipe/RecipeScaleEditButton.vue b/frontend/components/Domain/Recipe/RecipeScaleEditButton.vue index 97c2a04259a..01ffd3bd9ee 100644 --- a/frontend/components/Domain/Recipe/RecipeScaleEditButton.vue +++ b/frontend/components/Domain/Recipe/RecipeScaleEditButton.vue @@ -1,13 +1,12 @@ diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePage.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePage.vue index 69556434b99..071f8338cf8 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePage.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePage.vue @@ -24,7 +24,6 @@ - + - diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue index 6464004449f..8ab58a995d4 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue @@ -1,11 +1,6 @@