Merge pull request #2432 from smilerz/new_automations

add NEVER_UNIT automation
This commit is contained in:
vabene1111 2023-08-26 07:41:27 +02:00 committed by GitHub
commit 6ba4db6ff9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 297 additions and 124 deletions

View File

@ -3,8 +3,10 @@ import string
import unicodedata
from django.core.cache import caches
from django.db.models import Q
from django.db.models.functions import Lower
from cookbook.models import Unit, Food, Automation, Ingredient
from cookbook.models import Automation, Food, Ingredient, Unit
class IngredientParser:
@ -12,6 +14,8 @@ class IngredientParser:
ignore_rules = False
food_aliases = {}
unit_aliases = {}
never_unit = {}
transpose_words = {}
def __init__(self, request, cache_mode, ignore_automations=False):
"""
@ -29,7 +33,7 @@ class IngredientParser:
caches['default'].touch(FOOD_CACHE_KEY, 30)
else:
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').order_by('order').all():
self.food_aliases[a.param_1] = a.param_2
self.food_aliases[a.param_1.lower()] = a.param_2
caches['default'].set(FOOD_CACHE_KEY, self.food_aliases, 30)
UNIT_CACHE_KEY = f'automation_unit_alias_{self.request.space.pk}'
@ -38,11 +42,33 @@ class IngredientParser:
caches['default'].touch(UNIT_CACHE_KEY, 30)
else:
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
self.unit_aliases[a.param_1.lower()] = 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.lower()] = a.param_2
caches['default'].set(NEVER_UNIT_CACHE_KEY, self.never_unit, 30)
TRANSPOSE_WORDS_CACHE_KEY = f'automation_transpose_words_{self.request.space.pk}'
if c := caches['default'].get(TRANSPOSE_WORDS_CACHE_KEY, None):
self.transpose_words = c
caches['default'].touch(TRANSPOSE_WORDS_CACHE_KEY, 30)
else:
i = 0
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.TRANSPOSE_WORDS).only('param_1', 'param_2').order_by('order').all():
self.transpose_words[i] = [a.param_1.lower(), a.param_2.lower()]
i += 1
caches['default'].set(TRANSPOSE_WORDS_CACHE_KEY, self.transpose_words, 30)
else:
self.food_aliases = {}
self.unit_aliases = {}
self.never_unit = {}
self.transpose_words = {}
def apply_food_automation(self, food):
"""
@ -55,11 +81,11 @@ class IngredientParser:
else:
if self.food_aliases:
try:
return self.food_aliases[food]
return self.food_aliases[food.lower()]
except KeyError:
return food
else:
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1=food, disabled=False).order_by('order').first():
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1__iexact=food, disabled=False).order_by('order').first():
return automation.param_2
return food
@ -72,13 +98,13 @@ class IngredientParser:
if self.ignore_rules:
return unit
else:
if self.unit_aliases:
if self.transpose_words:
try:
return self.unit_aliases[unit]
return self.unit_aliases[unit.lower()]
except KeyError:
return unit
else:
if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1=unit, disabled=False).order_by('order').first():
if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1__iexact=unit, disabled=False).order_by('order').first():
return automation.param_2
return unit
@ -133,10 +159,10 @@ class IngredientParser:
end = 0
while (end < len(x) and (x[end] in string.digits
or (
(x[end] == '.' or x[end] == ',' or x[end] == '/')
and end + 1 < len(x)
and x[end + 1] in string.digits
))):
(x[end] == '.' or x[end] == ',' or x[end] == '/')
and end + 1 < len(x)
and x[end + 1] in string.digits
))):
end += 1
if end > 0:
if "/" in x[:end]:
@ -160,7 +186,8 @@ class IngredientParser:
if unit is not None and unit.strip() == '':
unit = None
if unit is not None and (unit.startswith('(') or unit.startswith('-')): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
if unit is not None and (unit.startswith('(') or unit.startswith(
'-')): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
unit = None
note = x
return amount, unit, note
@ -205,6 +232,67 @@ 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].lower()]
never_unit = True
except KeyError:
return tokens
else:
if automation := Automation.objects.annotate(param_1_lower=Lower('param_1')).filter(space=self.request.space, type=Automation.NEVER_UNIT, param_1_lower__in=[
tokens[1].lower(), alt_unit.lower()], 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 apply_transpose_words_automations(self, ingredient):
"""
If two words (param_1 & param_2) are detected in sequence, swap their position in the ingredient string
:param 1: first word to detect
:param 2: second word to detect
return: new ingredient string
"""
if self.ignore_rules:
return ingredient
else:
tokens = [x.lower() for x in ingredient.replace(',', ' ').split()]
if self.transpose_words:
filtered_rules = {}
for key, value in self.transpose_words.items():
if value[0] in tokens and value[1] in tokens:
filtered_rules[key] = value
for k, v in filtered_rules.items():
ingredient = re.sub(rf"\b({v[0]})\W*({v[1]})\b", r"\2 \1", ingredient, flags=re.IGNORECASE)
else:
for rule in Automation.objects.filter(space=self.request.space, type=Automation.TRANSPOSE_WORDS, disabled=False) \
.annotate(param_1_lower=Lower('param_1'), param_2_lower=Lower('param_2')) \
.filter(Q(Q(param_1_lower__in=tokens) | Q(param_2_lower__in=tokens))).order_by('order'):
ingredient = re.sub(rf"\b({rule.param_1})\W*({rule.param_1})\b", r"\2 \1", ingredient, flags=re.IGNORECASE)
return ingredient
def parse(self, ingredient):
"""
Main parsing function, takes an ingredient string (e.g. '1 l Water') and extracts amount, unit, food, ...
@ -230,8 +318,8 @@ class IngredientParser:
# if the string contains parenthesis early on remove it and place it at the end
# because its likely some kind of note
if re.match('(.){1,6}\s\((.[^\(\)])+\)\s', ingredient):
match = re.search('\((.[^\(])+\)', ingredient)
if re.match('(.){1,6}\\s\\((.[^\\(\\)])+\\)\\s', ingredient):
match = re.search('\\((.[^\\(])+\\)', ingredient)
ingredient = ingredient[:match.start()] + ingredient[match.end():] + ' ' + ingredient[match.start():match.end()]
# leading spaces before commas result in extra tokens, clean them out
@ -239,12 +327,14 @@ class IngredientParser:
# handle "(from) - (to)" amounts by using the minimum amount and adding the range to the description
# "10.5 - 200 g XYZ" => "100 g XYZ (10.5 - 200)"
ingredient = re.sub("^(\d+|\d+[\\.,]\d+) - (\d+|\d+[\\.,]\d+) (.*)", "\\1 \\3 (\\1 - \\2)", ingredient)
ingredient = re.sub("^(\\d+|\\d+[\\.,]\\d+) - (\\d+|\\d+[\\.,]\\d+) (.*)", "\\1 \\3 (\\1 - \\2)", ingredient)
# if amount and unit are connected add space in between
if re.match('([0-9])+([A-z])+\s', ingredient):
if re.match('([0-9])+([A-z])+\\s', ingredient):
ingredient = re.sub(r'(?<=([a-z])|\d)(?=(?(1)\d|[a-z]))', ' ', ingredient)
ingredient = self.apply_transpose_words_automations(ingredient)
tokens = ingredient.split() # split at each space into tokens
if len(tokens) == 1:
# there only is one argument, that must be the food
@ -257,6 +347,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.apply_never_unit_automations(tokens)
try:
if unit is not None:
# a unit is already found, no need to try the second argument for a fraction

View File

@ -15,7 +15,6 @@ from recipe_scrapers._utils import get_host_name, get_minutes
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.models import Automation, Keyword, PropertyType
# from unicodedata import decomposition
@ -51,7 +50,8 @@ def get_from_scraper(scrape, request):
recipe_json['internal'] = True
try:
servings = scrape.schema.data.get('recipeYield') or 1 # dont use scrape.yields() as this will always return "x servings" or "x items", should be improved in scrapers directly
# dont use scrape.yields() as this will always return "x servings" or "x items", should be improved in scrapers directly
servings = scrape.schema.data.get('recipeYield') or 1
except Exception:
servings = 1
@ -156,7 +156,14 @@ def get_from_scraper(scrape, request):
parsed_description = parse_description(description)
# TODO notify user about limit if reached
# limits exist to limit the attack surface for dos style attacks
automations = Automation.objects.filter(type=Automation.DESCRIPTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').all().order_by('order')[:512]
automations = Automation.objects.filter(
type=Automation.DESCRIPTION_REPLACE,
space=request.space,
disabled=False).only(
'param_1',
'param_2',
'param_3').all().order_by('order')[
:512]
for a in automations:
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
parsed_description = re.sub(a.param_2, a.param_3, parsed_description, count=1)
@ -206,7 +213,14 @@ def get_from_scraper(scrape, request):
pass
if 'source_url' in recipe_json and recipe_json['source_url']:
automations = Automation.objects.filter(type=Automation.INSTRUCTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').order_by('order').all()[:512]
automations = Automation.objects.filter(
type=Automation.INSTRUCTION_REPLACE,
space=request.space,
disabled=False).only(
'param_1',
'param_2',
'param_3').order_by('order').all()[
:512]
for a in automations:
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
for s in recipe_json['steps']:
@ -272,7 +286,7 @@ def get_from_youtube_scraper(url, request):
def parse_name(name):
if type(name) == list:
if isinstance(name, list):
try:
name = name[0]
except Exception:
@ -316,16 +330,16 @@ def parse_instructions(instructions):
"""
instruction_list = []
if type(instructions) == list:
if isinstance(instructions, list):
for i in instructions:
if type(i) == str:
if isinstance(i, str):
instruction_list.append(clean_instruction_string(i))
else:
if 'text' in i:
instruction_list.append(clean_instruction_string(i['text']))
elif 'itemListElement' in i:
for ile in i['itemListElement']:
if type(ile) == str:
if isinstance(ile, str):
instruction_list.append(clean_instruction_string(ile))
elif 'text' in ile:
instruction_list.append(clean_instruction_string(ile['text']))
@ -341,13 +355,13 @@ def parse_image(image):
# check if list of images is returned, take first if so
if not image:
return None
if type(image) == list:
if isinstance(image, list):
for pic in image:
if (type(pic) == str) and (pic[:4] == 'http'):
if (isinstance(pic, str)) and (pic[:4] == 'http'):
image = pic
elif 'url' in pic:
image = pic['url']
elif type(image) == dict:
elif isinstance(image, dict):
if 'url' in image:
image = image['url']
@ -358,12 +372,12 @@ def parse_image(image):
def parse_servings(servings):
if type(servings) == str:
if isinstance(servings, str):
try:
servings = int(re.search(r'\d+', servings).group())
except AttributeError:
servings = 1
elif type(servings) == list:
elif isinstance(servings, list):
try:
servings = int(re.findall(r'\b\d+\b', servings[0])[0])
except KeyError:
@ -372,12 +386,12 @@ def parse_servings(servings):
def parse_servings_text(servings):
if type(servings) == str:
if isinstance(servings, str):
try:
servings = re.sub("\d+", '', servings).strip()
servings = re.sub("\\d+", '', servings).strip()
except Exception:
servings = ''
if type(servings) == list:
if isinstance(servings, list):
try:
servings = parse_servings_text(servings[1])
except Exception:
@ -394,7 +408,7 @@ def parse_time(recipe_time):
recipe_time = round(iso_parse_duration(recipe_time).seconds / 60)
except ISO8601Error:
try:
if (type(recipe_time) == list and len(recipe_time) > 0):
if (isinstance(recipe_time, list) and len(recipe_time) > 0):
recipe_time = recipe_time[0]
recipe_time = round(parse_duration(recipe_time).seconds / 60)
except AttributeError:
@ -413,7 +427,7 @@ def parse_keywords(keyword_json, space):
caches['default'].touch(KEYWORD_CACHE_KEY, 30)
else:
for a in Automation.objects.filter(space=space, disabled=False, type=Automation.KEYWORD_ALIAS).only('param_1', 'param_2').order_by('order').all():
keyword_aliases[a.param_1] = a.param_2
keyword_aliases[a.param_1.lower()] = a.param_2
caches['default'].set(KEYWORD_CACHE_KEY, keyword_aliases, 30)
# keywords as list
@ -424,7 +438,7 @@ def parse_keywords(keyword_json, space):
if len(kw) != 0:
if keyword_aliases:
try:
kw = keyword_aliases[kw]
kw = keyword_aliases[kw.lower()]
except KeyError:
pass
if k := Keyword.objects.filter(name=kw, space=space).first():
@ -438,15 +452,15 @@ def parse_keywords(keyword_json, space):
def listify_keywords(keyword_list):
# keywords as string
try:
if type(keyword_list[0]) == dict:
if isinstance(keyword_list[0], dict):
return keyword_list
except (KeyError, IndexError):
pass
if type(keyword_list) == str:
if isinstance(keyword_list, str):
keyword_list = keyword_list.split(',')
# keywords as string in list
if (type(keyword_list) == list and len(keyword_list) == 1 and ',' in keyword_list[0]):
if (isinstance(keyword_list, list) and len(keyword_list) == 1 and ',' in keyword_list[0]):
keyword_list = keyword_list[0].split(',')
return [x.strip() for x in keyword_list]
@ -500,13 +514,13 @@ def get_images_from_soup(soup, url):
def clean_dict(input_dict, key):
if type(input_dict) == dict:
if isinstance(input_dict, dict):
for x in list(input_dict):
if x == key:
del input_dict[x]
elif type(input_dict[x]) == dict:
elif isinstance(input_dict[x], dict):
input_dict[x] = clean_dict(input_dict[x], key)
elif type(input_dict[x]) == list:
elif isinstance(input_dict[x], list):
temp_list = []
for e in input_dict[x]:
temp_list.append(clean_dict(e, key))

View File

@ -0,0 +1,34 @@
# Generated by Django 4.1.10 on 2023-08-25 13:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0198_propertytype_order'),
]
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'),
('TRANSPOSE_WORDS',
'Transpose Words')],
max_length=128),
),
]

View File

@ -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,
@ -770,7 +770,8 @@ class PropertyType(models.Model, PermissionModelMixin):
icon = models.CharField(max_length=16, blank=True, null=True)
order = models.IntegerField(default=0)
description = models.CharField(max_length=512, blank=True, null=True)
category = models.CharField(max_length=64, choices=((NUTRITION, _('Nutrition')), (ALLERGEN, _('Allergen')), (PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True)
category = models.CharField(max_length=64, choices=((NUTRITION, _('Nutrition')), (ALLERGEN, _('Allergen')),
(PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True)
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
# TODO show if empty property?
@ -1319,10 +1320,13 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis
KEYWORD_ALIAS = 'KEYWORD_ALIAS'
DESCRIPTION_REPLACE = 'DESCRIPTION_REPLACE'
INSTRUCTION_REPLACE = 'INSTRUCTION_REPLACE'
NEVER_UNIT = 'NEVER_UNIT'
TRANSPOSE_WORDS = 'TRANSPOSE_WORDS'
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')), (TRANSPOSE_WORDS, _('Transpose Words')),))
name = models.CharField(max_length=128, default='')
description = models.TextField(blank=True, null=True)

View File

@ -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(<parameter 2>, <parameter 2>, <descriotion>, count=1)`
@ -41,24 +43,52 @@ like this `re.sub(<parameter 2>, <parameter 2>, <descriotion>, 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 <empty> '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, ...).
## Transpose Words
Some recipes list the food before the units for some foods (garlic cloves). This automation will transpose 2 words in an
ingredient so "garlic cloves" will automatically become "cloves garlic"
- **Parameter 1**: first word to detect
- **Parameter 2**: second word to detect
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`
After processing rules XYZ, then ABC and then DEF the description will have the value `def`

View File

@ -525,6 +525,8 @@
"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",
"Transpose_Words": "Transpose Words"
}

View File

@ -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,13 @@ 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" },
{ value: "NEVER_UNIT", text: "Never_Unit" },
{ value: "TRANSPOSE_WORDS", text: "Transpose_Words" },
],
field: "type",
label: "Type",
@ -625,9 +627,8 @@ export class Models {
label: "Disabled",
placeholder: "",
},
form_function: "AutomationOrderDefault"
form_function: "AutomationOrderDefault",
},
},
}
@ -641,7 +642,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 +696,7 @@ export class Models {
help_text: "open_data_help_text",
optional: true,
},
},
},
}
@ -711,7 +710,7 @@ export class Models {
},
},
create: {
params: [['name', 'icon', 'unit', 'description','order']],
params: [["name", "icon", "unit", "description", "order"]],
form: {
show_help: true,
name: {
@ -764,7 +763,6 @@ export class Models {
optional: true,
},
},
},
}
@ -852,7 +850,7 @@ export class Models {
params: ["filter_list"],
},
create: {
params: [["name",]],
params: [["name"]],
form: {
name: {
form_field: true,
@ -1017,7 +1015,7 @@ export class Actions {
},
],
},
ok_label: {function: "translate", phrase: "Save"},
ok_label: { function: "translate", phrase: "Save" },
},
}
static UPDATE = {
@ -1052,7 +1050,7 @@ export class Actions {
},
],
},
ok_label: {function: "translate", phrase: "Delete"},
ok_label: { function: "translate", phrase: "Delete" },
instruction: {
form_field: true,
type: "instruction",
@ -1079,17 +1077,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 +1102,7 @@ export class Actions {
},
],
},
ok_label: {function: "translate", phrase: "Merge"},
ok_label: { function: "translate", phrase: "Merge" },
instruction: {
form_field: true,
type: "instruction",
@ -1138,8 +1136,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 +1152,7 @@ export class Actions {
},
],
},
ok_label: {function: "translate", phrase: "Move"},
ok_label: { function: "translate", phrase: "Move" },
instruction: {
form_field: true,
type: "instruction",