From 5cb4a1ade06f0dffad6278e5d943ce0043a4bd36 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Fri, 5 Nov 2021 15:48:10 -0800 Subject: [PATCH] Feature/recipe instructions improvements (#785) * feat(frontend): :sparkles: split paragraph by 1. 1) or 1: regex matches * feat(frontend): :sparkles: Update frontend to support ingredient To step refs * feat(backend): :sparkles: Update backend to support ingredient to step refs * fix title editor * move about info to site-settings * update change-log Co-authored-by: Hayden K --- docs/docs/changelog/v1.0.0.md | 27 +- .../Domain/Recipe/RecipeActionMenu.vue | 1 - .../Domain/Recipe/RecipeDialogBulkAdd.vue | 25 +- .../Domain/Recipe/RecipeInstructions.vue | 336 ++++++++++++++---- frontend/components/global/BaseButton.vue | 11 +- .../components/global/BaseOverflowButton.vue | 2 +- frontend/layouts/admin.vue | 5 - frontend/pages/admin/about.vue | 111 ------ frontend/pages/admin/site-settings.vue | 99 +++++- frontend/pages/recipe/_slug/cook.vue | 85 +++++ frontend/pages/recipe/_slug/index.vue | 16 +- frontend/pages/recipe/create.vue | 2 +- frontend/types/api-types/recipe.ts | 6 +- frontend/utils/icons/icons.ts | 2 + mealie/db/models/_model_utils/auto_init.py | 4 +- mealie/db/models/_model_utils/guid.py | 39 ++ mealie/db/models/_model_utils/helpers.py | 10 +- mealie/db/models/recipe/ingredient.py | 3 + mealie/db/models/recipe/instruction.py | 22 +- mealie/db/models/recipe/recipe.py | 10 +- mealie/schema/recipe/recipe_ingredient.py | 2 +- mealie/schema/recipe/recipe_step.py | 13 + 22 files changed, 621 insertions(+), 210 deletions(-) delete mode 100644 frontend/pages/admin/about.vue create mode 100644 frontend/pages/recipe/_slug/cook.vue create mode 100644 mealie/db/models/_model_utils/guid.py diff --git a/docs/docs/changelog/v1.0.0.md b/docs/docs/changelog/v1.0.0.md index a3a226ec142f..f8b74f7904da 100644 --- a/docs/docs/changelog/v1.0.0.md +++ b/docs/docs/changelog/v1.0.0.md @@ -15,14 +15,16 @@ - Mealie has gone through a big redesign and has tried to standardize it's look a feel a bit more across the board. - User/Group settings are now completely separated from the Administration page. - All settings and configurations pages now have some sort of self-documenting help text. Additional text or descriptions are welcome from PRs -- Site settings now has status on whether or not some ENV variables have been configured correctly. + + +**Site Settings Page** +- Site Settings has been completely revamped. All site-wide settings at defined on the server as ENV variables. The site settings page now only shows you the non-secret values for reference. It also has some helpers to let you know if something isn't configured correctly. - Server Side Bare URL will let you know if the BASE_URL env variable has been set - Secure Site let's you know if you're serving via HTTPS or accessing by localhost. accessing without a secure site will render some of the features unusable. - Email Configuration Status will let you know if all the email settings have been provided and offer a way to send test emails. ### ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Users and Groups -- Recipes are now only viewable by group members - All members of a group can generate invitation tokens for other users to join their group - Users now a have "Advanced" setting to enable/disable features like Webhooks and API tokens. This will also apply to future features that are deemed as advanced. - "Pages" have been dropped in favor of Cookbooks which are now group specific so each group can have it's own set of cookbooks @@ -37,17 +39,34 @@ - Add Recipes or Notes to a specific day ### ๐Ÿฅ™ Recipes + +**Recipe General** +- Recipes are now only viewable by group members - You can now import multiple URLs at a time pre-tagged using the bulk importer. This task runs in the background so no need to wait for it to finish. - Foods/Units for Ingredients are now supported (toggle inside your recipe settings) -- You can no use Natural Language Processing (NLP) to process ingredients and attempt to parse them into amounts, units, and foods. There additional is a "Brute Force" processor that can be used to use a pattern matching parser to try and determine ingredients. **Note** if you are processing a Non-English language you will have terrible results with the NLP and will likely need to use the Bruce Force processor. - Common Food and Units come pre-packaged with Mealie -- Recipes can now scale when Food/Units are properly defined - Landscape and Portrait views is now available - Users with the advanced flag turned on will not be able to manage recipe data in bulk and perform the following actions: - Set Categories - Set Tags - Delete Recipes - Export Recipes +- Recipes now have a `/cook` page for a simple view of the recipe where you can click through each step of a recipe and it's associated ingredients. +- The Bulk Importer has received many additional upgrades. + - Trim Whitespace: automatically removes leading and trailing whitespace + - Trim Prefix: Removes the first character of each line. Useful for when you paste in a list of ingredients or instructions that have 1. or 2. in front of them. + - Split By Numbered Line: Attempts to split a paragraph into multiple lines based on the patterns matching '1.', '1:' or '1)'. + +**Recipe Ingredients** +- Recipe ingredients can now be scaled when the food/unit is defined +- Recipe ingredients can no be copied as markdown lists + - example `- [ ] 1 cup of flour` +- You can no use Natural Language Processing (NLP) to process ingredients and attempt to parse them into amounts, units, and foods. There additional is a "Brute Force" processor that can be used to use a pattern matching parser to try and determine ingredients. **Note** if you are processing a Non-English language you will have terrible results with the NLP and will likely need to use the Bruce Force processor. + +**Recipe Instructions** +- Can now be merged with the above step automatically through the action menu +- Recipe Ingredients can be linked directly to recipe instructions for improved display + - There is an option in the linking dialog to automatically link ingredients. This works by using a key-word matching algorithm to find the ingredients. It's not perfect so you'll need to verify the links after use, additionally you will find that it doesn't work for non-english languages. ### โš ๏ธ Other things to know... - Themes have been depreciated for specific users. You can still set specific themes for your site through ENV variables. This approach should yield much better results for performance and some weirdness users have experienced. diff --git a/frontend/components/Domain/Recipe/RecipeActionMenu.vue b/frontend/components/Domain/Recipe/RecipeActionMenu.vue index c165cdde3f6c..a260a8eda54a 100644 --- a/frontend/components/Domain/Recipe/RecipeActionMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeActionMenu.vue @@ -6,7 +6,6 @@ color="rgb(255, 0, 0, 0.0)" flat style="z-index: 2; position: sticky" - :class="{ 'fixed-bar-mobile': $vuetify.breakpoint.xs }" >
- + Trim first character from each line + + + Attempts to split a paragraph by matching 1) or 1. patterns + @@ -74,6 +82,18 @@ export default defineComponent({ .join("\n"); } + const numberedLineRegex = /\d[.):] /gm; + + function splitByNumberedLine() { + // Split inputText by numberedLineRegex + const matches = state.inputText.match(numberedLineRegex); + + matches?.forEach((match, idx) => { + const replaceText = idx === 0 ? "" : "\n"; + state.inputText = state.inputText.replace(match, replaceText); + }); + } + function trimAllLines() { const splitLines = splitText(); @@ -93,6 +113,7 @@ export default defineComponent({ splitText, trimAllLines, removeFirstCharacter, + splitByNumberedLine, save, ...toRefs(state), }; diff --git a/frontend/components/Domain/Recipe/RecipeInstructions.vue b/frontend/components/Domain/Recipe/RecipeInstructions.vue index 679a10c6d706..1674745d57fb 100644 --- a/frontend/components/Domain/Recipe/RecipeInstructions.vue +++ b/frontend/components/Domain/Recipe/RecipeInstructions.vue @@ -1,6 +1,54 @@ - diff --git a/frontend/components/global/BaseButton.vue b/frontend/components/global/BaseButton.vue index 4acfa46efce5..6061595cb675 100644 --- a/frontend/components/global/BaseButton.vue +++ b/frontend/components/global/BaseButton.vue @@ -12,7 +12,7 @@ v-on="$listeners" @click="download ? downloadFile() : undefined" > - + {{ btnAttrs.icon }} @@ -20,6 +20,11 @@ {{ btnAttrs.text }} + + + {{ btnAttrs.icon }} + + @@ -96,6 +101,10 @@ export default { type: String, default: null, }, + iconRight: { + type: Boolean, + default: false, + }, }, setup() { const api = useApiSingleton(); diff --git a/frontend/components/global/BaseOverflowButton.vue b/frontend/components/global/BaseOverflowButton.vue index e060fdbd5388..c684a0fb8106 100644 --- a/frontend/components/global/BaseOverflowButton.vue +++ b/frontend/components/global/BaseOverflowButton.vue @@ -1,7 +1,7 @@ diff --git a/frontend/pages/recipe/_slug/index.vue b/frontend/pages/recipe/_slug/index.vue index ab4b97c3deb5..a4d50ee8ea0e 100644 --- a/frontend/pages/recipe/_slug/index.vue +++ b/frontend/pages/recipe/_slug/index.vue @@ -112,7 +112,7 @@ - +
{{ $t("general.new") }} @@ -431,12 +435,12 @@ export default defineComponent({ if (steps) { const cleanedSteps = steps.map((step) => { - return { text: step, title: "" }; + return { text: step, title: "", ingredientReferences: [] }; }); recipe.value.recipeInstructions.push(...cleanedSteps); } else { - recipe.value.recipeInstructions.push({ text: "", title: "" }); + recipe.value.recipeInstructions.push({ text: "", title: "", ingredientReferences: [] }); } } @@ -444,7 +448,7 @@ export default defineComponent({ if (ingredients?.length) { const newIngredients = ingredients.map((x) => { return { - ref: uuid4(), + referenceId: uuid4(), title: "", note: x, unit: null, @@ -459,7 +463,7 @@ export default defineComponent({ } } else { recipe?.value?.recipeIngredient?.push({ - ref: uuid4(), + referenceId: uuid4(), title: "", note: "", unit: null, diff --git a/frontend/pages/recipe/create.vue b/frontend/pages/recipe/create.vue index 9c9e0c87d7c9..c249360fda30 100644 --- a/frontend/pages/recipe/create.vue +++ b/frontend/pages/recipe/create.vue @@ -9,7 +9,7 @@ Select one of the various ways to create a recipe diff --git a/frontend/types/api-types/recipe.ts b/frontend/types/api-types/recipe.ts index 5033d4fb64c7..5eb8351db820 100644 --- a/frontend/types/api-types/recipe.ts +++ b/frontend/types/api-types/recipe.ts @@ -80,7 +80,7 @@ export interface Recipe { comments?: CommentOut[]; } export interface RecipeIngredient { - ref: string; + referenceId: string; title: string; note: string; unit?: RecipeIngredientUnit | null; @@ -96,9 +96,13 @@ export interface RecipeIngredientFood { name?: string; description?: string; } +export interface IngredientToStepRef { + referenceId: string; +} export interface RecipeStep { title?: string; text: string; + ingredientReferences: IngredientToStepRef[]; } export interface RecipeSettings { public?: boolean; diff --git a/frontend/utils/icons/icons.ts b/frontend/utils/icons/icons.ts index f9a93a62e5c7..943d95beb214 100644 --- a/frontend/utils/icons/icons.ts +++ b/frontend/utils/icons/icons.ts @@ -100,6 +100,7 @@ import { mdiArrowRightBoldOutline, mdiTimerSand, mdiRefresh, + mdiArrowRightBold, } from "@mdi/js"; export const icons = { @@ -113,6 +114,7 @@ export const icons = { alertCircle: mdiAlertCircle, api: mdiApi, arrowLeftBold: mdiArrowLeftBold, + arrowRightBold: mdiArrowRightBold, arrowUpDown: mdiDrag, backupRestore: mdiBackupRestore, bellAlert: mdiBellAlert, diff --git a/mealie/db/models/_model_utils/auto_init.py b/mealie/db/models/_model_utils/auto_init.py index 10875f74328e..25e316dce969 100644 --- a/mealie/db/models/_model_utils/auto_init.py +++ b/mealie/db/models/_model_utils/auto_init.py @@ -92,7 +92,7 @@ def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elemen updated_elems.append(existing_elem) - new_elems = [safe_call(relation_cls, elem) for elem in elems_to_create] + new_elems = [safe_call(relation_cls, elem, session=session) for elem in elems_to_create] return new_elems + updated_elems @@ -159,7 +159,7 @@ def auto_init(): # sourcery no-metrics setattr(self, key, instances) elif relation_dir == ONETOMANY: - instance = safe_call(relation_cls, val) + instance = safe_call(relation_cls, val, session=session) setattr(self, key, instance) elif relation_dir == MANYTOONE and not use_list: diff --git a/mealie/db/models/_model_utils/guid.py b/mealie/db/models/_model_utils/guid.py new file mode 100644 index 000000000000..b1f97bbc03f9 --- /dev/null +++ b/mealie/db/models/_model_utils/guid.py @@ -0,0 +1,39 @@ +import uuid + +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.types import CHAR, TypeDecorator + + +class GUID(TypeDecorator): + """Platform-independent GUID type. + Uses PostgreSQL's UUID type, otherwise uses + CHAR(32), storing as stringified hex values. + """ + + impl = CHAR + + def load_dialect_impl(self, dialect): + if dialect.name == "postgresql": + return dialect.type_descriptor(UUID()) + else: + return dialect.type_descriptor(CHAR(32)) + + def process_bind_param(self, value, dialect): + if value is None: + return value + elif dialect.name == "postgresql": + return str(value) + else: + if not isinstance(value, uuid.UUID): + return "%.32x" % uuid.UUID(value).int + else: + # hexstring + return "%.32x" % value.int + + def process_result_value(self, value, dialect): + if value is None: + return value + else: + if not isinstance(value, uuid.UUID): + value = uuid.UUID(value) + return value diff --git a/mealie/db/models/_model_utils/helpers.py b/mealie/db/models/_model_utils/helpers.py index fa0f56c1e592..0f6d7a79154d 100644 --- a/mealie/db/models/_model_utils/helpers.py +++ b/mealie/db/models/_model_utils/helpers.py @@ -28,12 +28,16 @@ def get_valid_call(func: Callable, args_dict) -> dict: return {k: v for k, v in args_dict.items() if k in valid_args} -def safe_call(func, dict) -> Any: +def safe_call(func, dict_args, **kwargs) -> Any: """ Safely calls the supplied function with the supplied dictionary of arguments. by removing any invalid arguments. """ + + if kwargs: + dict_args.update(kwargs) + try: - return func(**get_valid_call(func, dict)) + return func(**get_valid_call(func, dict_args)) except TypeError: - return func(**dict) + return func(**dict_args) diff --git a/mealie/db/models/recipe/ingredient.py b/mealie/db/models/recipe/ingredient.py index a7f050d0e886..d0a4cc715db7 100644 --- a/mealie/db/models/recipe/ingredient.py +++ b/mealie/db/models/recipe/ingredient.py @@ -3,6 +3,7 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from .._model_utils import auto_init +from .._model_utils.guid import GUID class IngredientUnitModel(SqlAlchemyBase, BaseMixins): @@ -48,6 +49,8 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins): food = orm.relationship(IngredientFoodModel, uselist=False) quantity = Column(Integer) + reference_id = Column(GUID()) # Reference Links + # Extras @auto_init() diff --git a/mealie/db/models/recipe/instruction.py b/mealie/db/models/recipe/instruction.py index f9e4eeaea3f4..23e9eb4d41f8 100644 --- a/mealie/db/models/recipe/instruction.py +++ b/mealie/db/models/recipe/instruction.py @@ -1,6 +1,18 @@ -from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy import Column, ForeignKey, Integer, String, orm -from mealie.db.models._model_base import SqlAlchemyBase +from .._model_base import BaseMixins, SqlAlchemyBase +from .._model_utils import auto_init +from .._model_utils.guid import GUID + + +class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins): + __tablename__ = "recipe_ingredient_ref_link" + instruction_id = Column(Integer, ForeignKey("recipe_instructions.id")) + reference_id = Column(GUID()) + + @auto_init() + def __init__(self, **_) -> None: + pass class RecipeInstruction(SqlAlchemyBase): @@ -11,3 +23,9 @@ class RecipeInstruction(SqlAlchemyBase): type = Column(String, default="") title = Column(String) text = Column(String) + + ingredient_references = orm.relationship("RecipeIngredientRefLink", cascade="all, delete-orphan") + + @auto_init() + def __init__(self, **_) -> None: + pass diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 1b4f71f40828..e05099257aa2 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -92,7 +92,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): "notes", "nutrition", "recipe_ingredient", - "recipe_instructions", "settings", "tools", } @@ -111,7 +110,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): notes: list[dict] = None, nutrition: dict = None, recipe_ingredient: list[str] = None, - recipe_instructions: list[dict] = None, settings: dict = None, tools: list[str] = None, **_, @@ -120,10 +118,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): self.tools = [Tool(tool=x) for x in tools] if tools else [] self.recipe_ingredient = [RecipeIngredient(**ingr, session=session) for ingr in recipe_ingredient] self.assets = [RecipeAsset(**a) for a in assets] - self.recipe_instructions = [ - RecipeInstruction(text=instruc.get("text"), title=instruc.get("title"), type=instruc.get("@type", None)) - for instruc in recipe_instructions - ] + # self.recipe_instructions = [ + # RecipeInstruction(text=instruc.get("text"), title=instruc.get("title"), type=instruc.get("@type", None)) + # for instruc in recipe_instructions + # ] # Mealie Specific self.settings = RecipeSettings(**settings) if settings else RecipeSettings() diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index 7189959b7cba..8fb33b097d3e 100644 --- a/mealie/schema/recipe/recipe_ingredient.py +++ b/mealie/schema/recipe/recipe_ingredient.py @@ -43,7 +43,7 @@ class RecipeIngredient(CamelModel): # Ref is used as a way to distinguish between an individual ingredient on the frontend # It is required for the reorder and section titles to function properly because of how # Vue handles reactivity. ref may serve another purpose in the future. - ref: UUID = Field(default_factory=uuid4) + reference_id: UUID = Field(default_factory=uuid4) class Config: orm_mode = True diff --git a/mealie/schema/recipe/recipe_step.py b/mealie/schema/recipe/recipe_step.py index ddcf676e5540..d0295a9931bd 100644 --- a/mealie/schema/recipe/recipe_step.py +++ b/mealie/schema/recipe/recipe_step.py @@ -1,11 +1,24 @@ from typing import Optional +from uuid import UUID from fastapi_camelcase import CamelModel +class IngredientReferences(CamelModel): + """ + A list of ingredient references. + """ + + reference_id: UUID = None + + class Config: + orm_mode = True + + class RecipeStep(CamelModel): title: Optional[str] = "" text: str + ingredient_references: list[IngredientReferences] = [] class Config: orm_mode = True