diff --git a/docs/docs/changelog/v1.0.0.md b/docs/docs/changelog/v1.0.0.md index a3a226ec1..f8b74f790 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 c165cdde3..a260a8eda 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 679a10c6d..1674745d5 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 4acfa46ef..6061595cb 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 e060fdbd5..c684a0fb8 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 ab4b97c3d..a4d50ee8e 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 9c9e0c87d..c249360fd 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 5033d4fb6..5eb8351db 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 f9a93a62e..943d95beb 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 10875f743..25e316dce 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 000000000..b1f97bbc0 --- /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 fa0f56c1e..0f6d7a791 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 a7f050d0e..d0a4cc715 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 f9e4eeaea..23e9eb4d4 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 1b4f71f40..e05099257 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 7189959b7..8fb33b097 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 ddcf676e5..d0295a993 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