diff --git a/cookbook/helper/ingredient_parser.py b/cookbook/helper/ingredient_parser.py index 8ecf299b..f1596f83 100644 --- a/cookbook/helper/ingredient_parser.py +++ b/cookbook/helper/ingredient_parser.py @@ -4,7 +4,7 @@ import unicodedata from django.core.cache import caches -from cookbook.models import Unit, Food, Automation, Ingredient +from cookbook.models import Automation, Food, Ingredient, Unit class IngredientParser: @@ -12,6 +12,7 @@ class IngredientParser: ignore_rules = False food_aliases = {} unit_aliases = {} + never_unit = {} def __init__(self, request, cache_mode, ignore_automations=False): """ @@ -40,9 +41,19 @@ class IngredientParser: for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').order_by('order').all(): self.unit_aliases[a.param_1] = a.param_2 caches['default'].set(UNIT_CACHE_KEY, self.unit_aliases, 30) + + NEVER_UNIT_CACHE_KEY = f'automation_never_unit_{self.request.space.pk}' + if c := caches['default'].get(NEVER_UNIT_CACHE_KEY, None): + self.never_unit = c + caches['default'].touch(NEVER_UNIT_CACHE_KEY, 30) + else: + for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.NEVER_UNIT).only('param_1', 'param_2').order_by('order').all(): + self.never_unit[a.param_1] = a.param_2 + caches['default'].set(NEVER_UNIT_CACHE_KEY, self.never_unit, 30) else: self.food_aliases = {} self.unit_aliases = {} + self.never_unit = {} def apply_food_automation(self, food): """ @@ -205,6 +216,49 @@ class IngredientParser: food, note = self.parse_food_with_comma(tokens) return food, note + def apply_never_unit_automations(self, tokens): + """ + Moves a string that should never be treated as a unit to next token and optionally replaced with default unit + e.g. NEVER_UNIT: param1: egg, param2: None would modify ['1', 'egg', 'white'] to ['1', '', 'egg', 'white'] + or NEVER_UNIT: param1: egg, param2: pcs would modify ['1', 'egg', 'yolk'] to ['1', 'pcs', 'egg', 'yolk'] + :param1 string: string that should never be considered a unit, will be moved to token[2] + :param2 (optional) unit as string: will insert unit string into token[1] + :return: unit as string (possibly changed by automation) + """ + + if self.ignore_rules: + return tokens + + new_unit = None + alt_unit = self.apply_unit_automation(tokens[1]) + never_unit = False + if self.never_unit: + try: + new_unit = self.never_unit[tokens[1]] + never_unit = True + except KeyError: + return tokens + + else: + if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1__in=[tokens[1], alt_unit], disabled=False).order_by('order').first(): + new_unit = automation.param_2 + never_unit = True + + if never_unit: + tokens.insert(1, new_unit) + + return tokens + + def parse_tokens(self, tokens): + """ + parser that applies automations to unmodified tokens + """ + + if self.ignore_rules: + return tokens + + return self.apply_never_unit_automations(tokens) + def parse(self, ingredient): """ Main parsing function, takes an ingredient string (e.g. '1 l Water') and extracts amount, unit, food, ... @@ -257,6 +311,7 @@ class IngredientParser: # three arguments if it already has a unit there can't be # a fraction for the amount if len(tokens) > 2: + tokens = self.parse_tokens(tokens) try: if unit is not None: # a unit is already found, no need to try the second argument for a fraction diff --git a/cookbook/migrations/0189_alter_automation_type_and_more.py b/cookbook/migrations/0189_alter_automation_type_and_more.py new file mode 100644 index 00000000..62d8f776 --- /dev/null +++ b/cookbook/migrations/0189_alter_automation_type_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.7 on 2023-04-24 15:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0188_space_no_sharing_limit'), + ] + + operations = [ + migrations.AlterField( + model_name='automation', + name='type', + field=models.CharField(choices=[('FOOD_ALIAS', 'Food Alias'), ('UNIT_ALIAS', 'Unit Alias'), ('KEYWORD_ALIAS', 'Keyword Alias'), ('DESCRIPTION_REPLACE', 'Description Replace'), ('INSTRUCTION_REPLACE', 'Instruction Replace'), ('NEVER_UNIT', 'Never Unit')], max_length=128), + ), + migrations.AlterField( + model_name='userpreference', + name='use_fractions', + field=models.BooleanField(default=True), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index d33ba4e6..e8a0f273 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -5,7 +5,6 @@ import uuid from datetime import date, timedelta import oauth2_provider.models -from PIL import Image from annoying.fields import AutoOneToOneField from django.contrib import auth from django.contrib.auth.models import Group, User @@ -14,13 +13,14 @@ from django.contrib.postgres.search import SearchVectorField from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile from django.core.validators import MinLengthValidator from django.db import IntegrityError, models -from django.db.models import Index, ProtectedError, Q, Avg, Max +from django.db.models import Avg, Index, Max, ProtectedError, Q from django.db.models.fields.related import ManyToManyField from django.db.models.functions import Substr from django.utils import timezone from django.utils.translation import gettext as _ from django_prometheus.models import ExportModelOperationsMixin from django_scopes import ScopedManager, scopes_disabled +from PIL import Image from treebeard.mp_tree import MP_Node, MP_NodeManager from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT, @@ -1319,10 +1319,12 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis KEYWORD_ALIAS = 'KEYWORD_ALIAS' DESCRIPTION_REPLACE = 'DESCRIPTION_REPLACE' INSTRUCTION_REPLACE = 'INSTRUCTION_REPLACE' + NEVER_UNIT = 'NEVER_UNIT' type = models.CharField(max_length=128, choices=((FOOD_ALIAS, _('Food Alias')), (UNIT_ALIAS, _('Unit Alias')), (KEYWORD_ALIAS, _('Keyword Alias')), - (DESCRIPTION_REPLACE, _('Description Replace')), (INSTRUCTION_REPLACE, _('Instruction Replace')),)) + (DESCRIPTION_REPLACE, _('Description Replace')), (INSTRUCTION_REPLACE, _('Instruction Replace')), + (NEVER_UNIT, _('Never Unit')),)) name = models.CharField(max_length=128, default='') description = models.TextField(blank=True, null=True) diff --git a/docs/features/automation.md b/docs/features/automation.md index 66f9fefd..bd76dec9 100644 --- a/docs/features/automation.md +++ b/docs/features/automation.md @@ -1,39 +1,41 @@ !!! warning - Automations are currently in a beta stage. They work pretty stable but if I encounter any - issues while working on them, I might change how they work breaking existing automations. - I will try to avoid this and am pretty confident it won't happen. +Automations are currently in a beta stage. They work pretty stable but if I encounter any +issues while working on them, I might change how they work breaking existing automations. +I will try to avoid this and am pretty confident it won't happen. - -Automations allow Tandoor to automatically perform certain tasks, especially when importing recipes, that +Automations allow Tandoor to automatically perform certain tasks, especially when importing recipes, that would otherwise have to be done manually. Currently, the following automations are supported. ## Unit, Food, Keyword Alias + Foods, Units and Keywords can have automations that automatically replace them with another object -to allow aliasing them. +to allow aliasing them. This helps to add consistency to the naming of objects, for example to always use the singular form -for the main name if a plural form is configured. +for the main name if a plural form is configured. -These automations are best created by dragging and dropping Foods, Units or Keywords in their respective -views and creating the automation there. +These automations are best created by dragging and dropping Foods, Units or Keywords in their respective +views and creating the automation there. You can also create them manually by setting the following -- **Parameter 1**: name of food/unit/keyword to match -- **Parameter 2**: name of food/unit/keyword to replace matched food with + +- **Parameter 1**: name of food/unit/keyword to match +- **Parameter 2**: name of food/unit/keyword to replace matched food with These rules are processed whenever you are importing recipes from websites or other apps and when using the simple ingredient input (shopping, recipe editor, ...). ## Description Replace -This automation is a bit more complicated than the alis rules. It is run when importing a recipe + +This automation is a bit more complicated than the alias rules. It is run when importing a recipe from a website. It uses Regular Expressions (RegEx) to determine if a description should be altered, what exactly to remove -and what to replace it with. +and what to replace it with. -- **Parameter 1**: pattern of which sites to match (e.g. `.*.chefkoch.de.*`, `.*`) -- **Parameter 2**: pattern of what to replace (e.g. `.*`) -- **Parameter 3**: value to replace matched occurrence of parameter 2 with. Only one occurrence of the pattern is replaced. +- **Parameter 1**: pattern of which sites to match (e.g. `.*.chefkoch.de.*`, `.*`) +- **Parameter 2**: pattern of what to replace (e.g. `.*`) +- **Parameter 3**: value to replace matched occurrence of parameter 2 with. Only one occurrence of the pattern is replaced. To replace the description the python [re.sub](https://docs.python.org/2/library/re.html#re.sub) function is used like this `re.sub(, , , count=1)` @@ -41,24 +43,41 @@ like this `re.sub(, , , count=1)` To test out your patterns and learn about RegEx you can use [regexr.com](https://regexr.com/) !!! info - In order to prevent denial of service attacks on the RegEx engine the number of replace automations - and the length of the inputs that are processed are limited. Those limits should never be reached - during normal usage. +In order to prevent denial of service attacks on the RegEx engine the number of replace automations +and the length of the inputs that are processed are limited. Those limits should never be reached +during normal usage. ## Instruction Replace + This works just like the Description Replace automation but runs against all instruction texts -in all steps of a recipe during import. +in all steps of a recipe during import. Also instead of just replacing a single occurrence of the matched pattern it will replace all. +## Never Unit + +Some ingredients have a pattern of AMOUNT and FOOD, if the food has multiple words (e.g. egg yolk) this can cause Tandoor +to detect the word "egg" as a unit. This automation will detect the word 'egg' as something that should never be considered +a unit. + +You can also create them manually by setting the following + +- **Parameter 1**: string to detect +- **Parameter 2**: Optional: unit to insert into ingredient (e.g. 1 whole 'egg yolk' instead of 1 'egg yolk') + +These rules are processed whenever you are importing recipes from websites or other apps +and when using the simple ingredient input (shopping, recipe editor, ...). + # Order -If the Automation type allows for more than one rule to be executed (for example description replace) -the rules are processed in ascending order (ordered by the *order* property of the automation). -The default order is always 1000 to make it easier to add automations before and after other automations. + +If the Automation type allows for more than one rule to be executed (for example description replace) +the rules are processed in ascending order (ordered by the _order_ property of the automation). +The default order is always 1000 to make it easier to add automations before and after other automations. Example: + 1. Rule ABC (order 1000) replaces `everything` with `abc` 2. Rule DEF (order 2000) replaces `everything` with `def` 3. Rule XYZ (order 500) replaces `everything` with `xyz` -After processing rules XYZ, then ABC and then DEF the description will have the value `def` \ No newline at end of file +After processing rules XYZ, then ABC and then DEF the description will have the value `def` diff --git a/vue/src/locales/en.json b/vue/src/locales/en.json index d4b5670a..fdc4149d 100644 --- a/vue/src/locales/en.json +++ b/vue/src/locales/en.json @@ -525,6 +525,7 @@ "Use_Plural_Food_Always": "Use plural form for food always", "Use_Plural_Food_Simple": "Use plural form for food dynamically", "plural_usage_info": "Use the plural form for units and food inside this space.", - "Create Recipe": "Create Recipe", - "Import Recipe": "Import Recipe" + "Create Recipe": "Create Recipe", + "Import Recipe": "Import Recipe", + "Never_Unit": "Never Unit" } diff --git a/vue/src/utils/models.js b/vue/src/utils/models.js index 2263bd47..cce65d97 100644 --- a/vue/src/utils/models.js +++ b/vue/src/utils/models.js @@ -23,7 +23,7 @@ export class Models { false: undefined, }, }, - tree: {default: undefined}, + tree: { default: undefined }, }, }, delete: { @@ -50,7 +50,7 @@ export class Models { type: "lookup", field: "target", list: "self", - sticky_options: [{id: 0, name: "tree_root"}], + sticky_options: [{ id: 0, name: "tree_root" }], }, }, }, @@ -71,7 +71,7 @@ export class Models { food_onhand: true, shopping: true, }, - tags: [{field: "supermarket_category", label: "name", color: "info"}], + tags: [{ field: "supermarket_category", label: "name", color: "info" }], // REQUIRED: unordered array of fields that can be set during create create: { // if not defined partialUpdate will use the same parameters, prepending 'id' @@ -177,7 +177,7 @@ export class Models { field: "substitute_siblings", label: "substitute_siblings", // form.label always translated in utils.getForm() help_text: "substitute_siblings_help", // form.help_text always translated - condition: {field: "parent", value: true, condition: "field_exists"}, + condition: { field: "parent", value: true, condition: "field_exists" }, }, substitute_children: { form_field: true, @@ -186,7 +186,7 @@ export class Models { field: "substitute_children", label: "substitute_children", help_text: "substitute_children_help", - condition: {field: "numchild", value: 0, condition: "gt"}, + condition: { field: "numchild", value: 0, condition: "gt" }, }, inherit_fields: { form_field: true, @@ -196,7 +196,7 @@ export class Models { field: "inherit_fields", list: "FOOD_INHERIT_FIELDS", label: "InheritFields", - condition: {field: "food_children_exist", value: true, condition: "preference_equals"}, + condition: { field: "food_children_exist", value: true, condition: "preference_equals" }, help_text: "InheritFields_help", }, child_inherit_fields: { @@ -207,7 +207,7 @@ export class Models { field: "child_inherit_fields", list: "FOOD_INHERIT_FIELDS", label: "ChildInheritFields", // form.label always translated in utils.getForm() - condition: {field: "numchild", value: 0, condition: "gt"}, + condition: { field: "numchild", value: 0, condition: "gt" }, help_text: "ChildInheritFields_help", // form.help_text always translated }, reset_inherit: { @@ -217,7 +217,7 @@ export class Models { field: "reset_inherit", label: "reset_children", help_text: "reset_children_help", - condition: {field: "numchild", value: 0, condition: "gt"}, + condition: { field: "numchild", value: 0, condition: "gt" }, }, form_function: "FoodCreateDefault", }, @@ -281,7 +281,7 @@ export class Models { apiName: "Unit", paginated: true, create: { - params: [["name", "plural_name", "description", "base_unit", "open_data_slug",]], + params: [["name", "plural_name", "description", "base_unit", "open_data_slug"]], form: { show_help: true, name: { @@ -311,24 +311,24 @@ export class Models { form_field: true, type: "choice", options: [ - {value: "g", text: "g"}, - {value: "kg", text: "kg"}, - {value: "ounce", text: "ounce"}, - {value: "pound", text: "pound"}, - {value: "ml", text: "ml"}, - {value: "l", text: "l"}, - {value: "fluid_ounce", text: "fluid_ounce"}, - {value: "pint", text: "pint"}, - {value: "quart", text: "quart"}, - {value: "gallon", text: "gallon"}, - {value: "tbsp", text: "tbsp"}, - {value: "tsp", text: "tsp"}, - {value: "imperial_fluid_ounce", text: "imperial_fluid_ounce"}, - {value: "imperial_pint", text: "imperial_pint"}, - {value: "imperial_quart", text: "imperial_quart"}, - {value: "imperial_gallon", text: "imperial_gallon"}, - {value: "imperial_tbsp", text: "imperial_tbsp"}, - {value: "imperial_tsp", text: "imperial_tsp"}, + { value: "g", text: "g" }, + { value: "kg", text: "kg" }, + { value: "ounce", text: "ounce" }, + { value: "pound", text: "pound" }, + { value: "ml", text: "ml" }, + { value: "l", text: "l" }, + { value: "fluid_ounce", text: "fluid_ounce" }, + { value: "pint", text: "pint" }, + { value: "quart", text: "quart" }, + { value: "gallon", text: "gallon" }, + { value: "tbsp", text: "tbsp" }, + { value: "tsp", text: "tsp" }, + { value: "imperial_fluid_ounce", text: "imperial_fluid_ounce" }, + { value: "imperial_pint", text: "imperial_pint" }, + { value: "imperial_quart", text: "imperial_quart" }, + { value: "imperial_gallon", text: "imperial_gallon" }, + { value: "imperial_tbsp", text: "imperial_tbsp" }, + { value: "imperial_tsp", text: "imperial_tsp" }, ], field: "base_unit", label: "Base Unit", @@ -470,7 +470,7 @@ export class Models { static SUPERMARKET = { name: "Supermarket", apiName: "Supermarket", - ordered_tags: [{field: "category_to_supermarket", label: "category::name", color: "info"}], + ordered_tags: [{ field: "category_to_supermarket", label: "category::name", color: "info" }], create: { params: [["name", "description", "category_to_supermarket"]], form: { @@ -553,11 +553,11 @@ export class Models { form_field: true, type: "choice", options: [ - {value: "FOOD_ALIAS", text: "Food_Alias"}, - {value: "UNIT_ALIAS", text: "Unit_Alias"}, - {value: "KEYWORD_ALIAS", text: "Keyword_Alias"}, - {value: "DESCRIPTION_REPLACE", text: "Description_Replace"}, - {value: "INSTRUCTION_REPLACE", text: "Instruction_Replace"}, + { value: "FOOD_ALIAS", text: "Food_Alias" }, + { value: "UNIT_ALIAS", text: "Unit_Alias" }, + { value: "KEYWORD_ALIAS", text: "Keyword_Alias" }, + { value: "DESCRIPTION_REPLACE", text: "Description_Replace" }, + { value: "INSTRUCTION_REPLACE", text: "Instruction_Replace" }, ], field: "type", label: "Type", @@ -625,9 +625,8 @@ export class Models { label: "Disabled", placeholder: "", }, - form_function: "AutomationOrderDefault" + form_function: "AutomationOrderDefault", }, - }, } @@ -641,7 +640,7 @@ export class Models { }, }, create: { - params: [['food', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'open_data_slug']], + params: [["food", "base_amount", "base_unit", "converted_amount", "converted_unit", "open_data_slug"]], form: { show_help: true, // TODO add proper help texts for everything @@ -695,9 +694,7 @@ export class Models { help_text: "open_data_help_text", optional: true, }, - }, - }, } @@ -711,7 +708,7 @@ export class Models { }, }, create: { - params: [['name', 'icon', 'unit', 'description','order']], + params: [["name", "icon", "unit", "description", "order"]], form: { show_help: true, name: { @@ -764,7 +761,6 @@ export class Models { optional: true, }, }, - }, } @@ -852,7 +848,7 @@ export class Models { params: ["filter_list"], }, create: { - params: [["name",]], + params: [["name"]], form: { name: { form_field: true, @@ -1017,7 +1013,7 @@ export class Actions { }, ], }, - ok_label: {function: "translate", phrase: "Save"}, + ok_label: { function: "translate", phrase: "Save" }, }, } static UPDATE = { @@ -1052,7 +1048,7 @@ export class Actions { }, ], }, - ok_label: {function: "translate", phrase: "Delete"}, + ok_label: { function: "translate", phrase: "Delete" }, instruction: { form_field: true, type: "instruction", @@ -1079,17 +1075,17 @@ export class Actions { suffix: "s", params: ["query", "page", "pageSize", "options"], config: { - query: {default: undefined}, - page: {default: 1}, - pageSize: {default: 25}, + query: { default: undefined }, + page: { default: 1 }, + pageSize: { default: 25 }, }, } static MERGE = { function: "merge", params: ["source", "target"], config: { - source: {type: "string"}, - target: {type: "string"}, + source: { type: "string" }, + target: { type: "string" }, }, form: { title: { @@ -1104,7 +1100,7 @@ export class Actions { }, ], }, - ok_label: {function: "translate", phrase: "Merge"}, + ok_label: { function: "translate", phrase: "Merge" }, instruction: { form_field: true, type: "instruction", @@ -1138,8 +1134,8 @@ export class Actions { function: "move", params: ["source", "target"], config: { - source: {type: "string"}, - target: {type: "string"}, + source: { type: "string" }, + target: { type: "string" }, }, form: { title: { @@ -1154,7 +1150,7 @@ export class Actions { }, ], }, - ok_label: {function: "translate", phrase: "Move"}, + ok_label: { function: "translate", phrase: "Move" }, instruction: { form_field: true, type: "instruction",