mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-24 02:39:20 -05:00
Merge branch 'develop' into beta
This commit is contained in:
@@ -30,9 +30,11 @@
|
||||

|
||||
|
||||
## Core Features
|
||||
|
||||
- 🥗 **Manage your recipes** - Manage your ever growing recipe collection
|
||||
- 📆 **Plan** - multiple meals for each day
|
||||
- 🛒 **Shopping lists** - via the meal plan or straight from recipes
|
||||
- 🪄 **use AI** to recognize images, sort recipe steps, find nutrition facts and more
|
||||
- 📚 **Cookbooks** - collect recipes into books
|
||||
- 👪 **Share and collaborate** on recipes with friends and family
|
||||
|
||||
@@ -62,12 +64,13 @@ a public page.
|
||||
|
||||
Documentation can be found [here](https://docs.tandoor.dev/).
|
||||
|
||||
## Support our work
|
||||
## ❤️ Support our work ❤️
|
||||
Tandoor is developed by volunteers in their free time just because its fun. That said earning
|
||||
some money with the project allows us to spend more time on it and thus make improvements we otherwise couldn't.
|
||||
Because of that there are several ways you can support us
|
||||
|
||||
- **GitHub Sponsors** You can sponsor contributors of this project on GitHub: [vabene1111](https://github.com/sponsors/vabene1111)
|
||||
- **Patron** You can sponsor contributors of this project on Patron: [vabene111](https://www.patreon.com/cw/vabene1111)
|
||||
- **Host at Hetzner** We have been very happy customers of Hetzner for multiple years for all of our projects. If you want to get into self-hosting or are tired of the expensive big providers, their cloud servers are a great place to get started. When you sign up via our [referral link](https://hetzner.cloud/?ref=ISdlrLmr9kGj) you will get 20€ worth of cloud credits and we get a small kickback too.
|
||||
- **Let us host for you** We are offering a [hosted version](https://app.tandoor.dev) where all profits support us and the development of tandoor (currently only available in germany).
|
||||
|
||||
|
||||
31
boot.sh
31
boot.sh
@@ -2,7 +2,7 @@
|
||||
source venv/bin/activate
|
||||
|
||||
# these are envsubst in the nginx config, make sure they default to something sensible when unset
|
||||
export TANDOOR_PORT="${TANDOOR_PORT:-8080}"
|
||||
export TANDOOR_PORT="${TANDOOR_PORT:-80}"
|
||||
export MEDIA_ROOT=${MEDIA_ROOT:-/opt/recipes/mediafiles};
|
||||
export STATIC_ROOT=${STATIC_ROOT:-/opt/recipes/staticfiles};
|
||||
|
||||
@@ -12,16 +12,18 @@ GUNICORN_LOG_LEVEL="${GUNICORN_LOG_LEVEL:-'info'}"
|
||||
|
||||
PLUGINS_BUILD="${PLUGINS_BUILD:-0}"
|
||||
|
||||
if [ "${TANDOOR_PORT}" -eq 80 ]; then
|
||||
echo "TANDOOR_PORT set to 8080 because 80 is now taken by the integrated nginx"
|
||||
TANDOOR_PORT=8080
|
||||
fi
|
||||
|
||||
display_warning() {
|
||||
echo "[WARNING]"
|
||||
echo -e "$1"
|
||||
}
|
||||
|
||||
# prepare nginx config
|
||||
envsubst '$MEDIA_ROOT $STATIC_ROOT $TANDOOR_PORT' < /opt/recipes/http.d/Recipes.conf.template > /opt/recipes/http.d/Recipes.conf
|
||||
|
||||
# start nginx early to display error pages
|
||||
echo "Starting nginx"
|
||||
nginx
|
||||
|
||||
echo "Checking configuration..."
|
||||
|
||||
# SECRET_KEY (or a valid file at SECRET_KEY_FILE) must be set in .env file
|
||||
@@ -93,7 +95,7 @@ fi
|
||||
|
||||
echo "Collecting static files, this may take a while..."
|
||||
|
||||
python manage.py collectstatic --noinput
|
||||
python manage.py collectstatic --noinput --clear
|
||||
|
||||
echo "Done"
|
||||
|
||||
@@ -101,17 +103,6 @@ chmod -R 755 ${MEDIA_ROOT:-/opt/recipes/mediafiles}
|
||||
|
||||
ipv6_disable=$(cat /sys/module/ipv6/parameters/disable)
|
||||
|
||||
# prepare nginx config
|
||||
envsubst '$MEDIA_ROOT $STATIC_ROOT $TANDOOR_PORT' < /opt/recipes/http.d/Recipes.conf.template > /opt/recipes/http.d/Recipes.conf
|
||||
|
||||
# start nginx
|
||||
echo "Starting nginx"
|
||||
nginx
|
||||
|
||||
echo "Starting gunicorn"
|
||||
# Check if IPv6 is enabled, only then run gunicorn with ipv6 support
|
||||
if [ "$ipv6_disable" -eq 0 ]; then
|
||||
exec gunicorn -b "[::]:$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
else
|
||||
exec gunicorn -b ":$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
fi
|
||||
exec gunicorn --bind unix:/run/tandoor.sock --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ class RecipeSearch():
|
||||
|
||||
def _updated_on_filter(self):
|
||||
if self._updatedon:
|
||||
self._queryset = self._queryset.filter(updated_at__date__date=self._updatedon)
|
||||
self._queryset = self._queryset.filter(updated_at__date=self._updatedon)
|
||||
elif self._updatedon_lte:
|
||||
self._queryset = self._queryset.filter(updated_at__date__lte=self._updatedon_lte)
|
||||
elif self._updatedon_gte:
|
||||
@@ -326,7 +326,7 @@ class RecipeSearch():
|
||||
def _favorite_recipes(self):
|
||||
if self._sort_includes('favorite') or self._timescooked or self._timescooked_gte or self._timescooked_lte:
|
||||
less_than = self._timescooked_lte and not self._sort_includes('-favorite')
|
||||
if less_than:
|
||||
if less_than or self._timescooked == 0:
|
||||
default = 1000
|
||||
else:
|
||||
default = 0
|
||||
@@ -339,7 +339,7 @@ class RecipeSearch():
|
||||
self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default))
|
||||
|
||||
if self._timescooked:
|
||||
self._queryset = self._queryset.filter(favorite=0)
|
||||
self._queryset = self._queryset.filter(favorite=self._timescooked)
|
||||
elif self._timescooked_lte:
|
||||
self._queryset = self._queryset.filter(favorite__lte=int(self._timescooked_lte)).exclude(favorite=0)
|
||||
elif self._timescooked_gte:
|
||||
|
||||
@@ -155,7 +155,7 @@ def get_from_scraper(scrape, request):
|
||||
|
||||
# assign steps
|
||||
try:
|
||||
for i in parse_instructions(scrape.instructions()):
|
||||
for i in parse_instructions(scrape.instructions_list()):
|
||||
recipe_json['steps'].append({
|
||||
'instruction': i,
|
||||
'ingredients': [],
|
||||
@@ -177,11 +177,11 @@ def get_from_scraper(scrape, request):
|
||||
for x in scrape.ingredients():
|
||||
if x.strip() != '':
|
||||
try:
|
||||
amount, unit, ingredient, note = ingredient_parser.parse(x)
|
||||
amount, unit, food, note = ingredient_parser.parse(x)
|
||||
ingredient = {
|
||||
'amount': amount,
|
||||
'food': {
|
||||
'name': ingredient,
|
||||
'name': food,
|
||||
},
|
||||
'unit': None,
|
||||
'note': note,
|
||||
|
||||
@@ -135,8 +135,9 @@ class UnitConversionHelper:
|
||||
:param food: base food
|
||||
:return: converted ingredient object from base amount/unit/food
|
||||
"""
|
||||
if uc.food is None or uc.food == food:
|
||||
if (uc.food is None or uc.food == food) and uc.converted_amount > 0 and uc.base_amount > 0:
|
||||
if unit == uc.base_unit:
|
||||
return Ingredient(amount=amount * (uc.converted_amount / uc.base_amount), unit=uc.converted_unit, food=food, space=self.space)
|
||||
else:
|
||||
return Ingredient(amount=amount * (uc.base_amount / uc.converted_amount), unit=uc.base_unit, food=food, space=self.space)
|
||||
return None
|
||||
|
||||
@@ -128,6 +128,7 @@ class Mealie1(Integration):
|
||||
|
||||
steps_relation = []
|
||||
first_step_of_recipe_dict = {}
|
||||
step_id_dict = {}
|
||||
for s in mealie_database['recipe_instructions']:
|
||||
if s['recipe_id'] in recipes_dict:
|
||||
step = Step.objects.create(instruction=(s['text'] if s['text'] else "") + (f" \n {s['summary']}" if s['summary'] else ""),
|
||||
@@ -135,9 +136,20 @@ class Mealie1(Integration):
|
||||
name=s['title'],
|
||||
space=self.request.space)
|
||||
steps_relation.append(Recipe.steps.through(recipe_id=recipes_dict[s['recipe_id']], step_id=step.pk))
|
||||
step_id_dict[s["id"]] = step.pk
|
||||
if s['recipe_id'] not in first_step_of_recipe_dict:
|
||||
first_step_of_recipe_dict[s['recipe_id']] = step.pk
|
||||
|
||||
# it is possible for a recipe to not have steps but have ingredients, in that case create an empty step to add them to later
|
||||
for r in recipes_dict.keys():
|
||||
if r not in first_step_of_recipe_dict:
|
||||
step = Step.objects.create(instruction='',
|
||||
order=0,
|
||||
name='',
|
||||
space=self.request.space)
|
||||
steps_relation.append(Recipe.steps.through(recipe_id=recipes_dict[r], step_id=step.pk))
|
||||
first_step_of_recipe_dict[r] = step.pk
|
||||
|
||||
for n in mealie_database['notes']:
|
||||
if n['recipe_id'] in recipes_dict:
|
||||
step = Step.objects.create(instruction=n['text'],
|
||||
@@ -153,6 +165,11 @@ class Mealie1(Integration):
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipes_ingredients"])} ingredients...\n"
|
||||
self.import_log.save()
|
||||
|
||||
# mealie stores the reference to a step (instruction) from an ingredient (reference) in the recipe_ingredient_ref_link table
|
||||
recipe_ingredient_ref_link_dict = {}
|
||||
for ref in mealie_database['recipe_ingredient_ref_link']:
|
||||
recipe_ingredient_ref_link_dict[ref["reference_id"]] = ref["instruction_id"]
|
||||
|
||||
ingredients_relation = []
|
||||
for i in mealie_database['recipes_ingredients']:
|
||||
if i['recipe_id'] in recipes_dict:
|
||||
@@ -162,18 +179,18 @@ class Mealie1(Integration):
|
||||
is_header=True,
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=title_ingredient.pk))
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=get_step_id(i, first_step_of_recipe_dict, step_id_dict,recipe_ingredient_ref_link_dict), ingredient_id=title_ingredient.pk))
|
||||
if i['food_id']:
|
||||
ingredient = Ingredient.objects.create(
|
||||
food_id=foods_dict[i['food_id']] if i['food_id'] in foods_dict else None,
|
||||
unit_id=units_dict[i['unit_id']] if i['unit_id'] in units_dict else None,
|
||||
original_text=i['original_text'],
|
||||
order=i['position'],
|
||||
amount=i['quantity'],
|
||||
amount=i['quantity'] if i['quantity'] else 0,
|
||||
note=i['note'],
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=ingredient.pk))
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=get_step_id(i, first_step_of_recipe_dict, step_id_dict,recipe_ingredient_ref_link_dict), ingredient_id=ingredient.pk))
|
||||
elif i['note'].strip():
|
||||
amount, unit, food, note = ingredient_parser.parse(i['note'].strip())
|
||||
f = ingredient_parser.get_food(food)
|
||||
@@ -186,7 +203,7 @@ class Mealie1(Integration):
|
||||
original_text=i['original_text'],
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=ingredient.pk))
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=get_step_id(i, first_step_of_recipe_dict, step_id_dict,recipe_ingredient_ref_link_dict), ingredient_id=ingredient.pk))
|
||||
Step.ingredients.through.objects.bulk_create(ingredients_relation)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipes_to_categories"]) + len(mealie_database["recipes_to_tags"])} category and keyword relations...\n"
|
||||
@@ -340,3 +357,10 @@ class Mealie1(Integration):
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
|
||||
def get_step_id(i, first_step_of_recipe_dict, step_id_dict, recipe_ingredient_ref_link_dict):
|
||||
try:
|
||||
return step_id_dict[recipe_ingredient_ref_link_dict[i['reference_id']]]
|
||||
except KeyError:
|
||||
return first_step_of_recipe_dict[i['recipe_id']]
|
||||
@@ -16,7 +16,7 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from cookbook.models import SearchFields
|
||||
|
||||
from django.contrib.postgres.operations import TrigramExtension, UnaccentExtension
|
||||
|
||||
def allSearchFields():
|
||||
return list(SearchFields.objects.values_list('id', flat=True))
|
||||
@@ -141,6 +141,8 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
TrigramExtension(),
|
||||
UnaccentExtension(),
|
||||
migrations.RunPython(create_default_groups),
|
||||
migrations.CreateModel(
|
||||
name='AiProvider',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-24 17:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0228_space_space_setup_completed'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='ailog',
|
||||
options={'ordering': ('-created_at',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='aiprovider',
|
||||
options={'ordering': ('id',)},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='storage',
|
||||
name='token',
|
||||
field=models.CharField(blank=True, max_length=4098, null=True),
|
||||
),
|
||||
]
|
||||
15
cookbook/migrations/0230_auto_20250925_2056.py
Normal file
15
cookbook/migrations/0230_auto_20250925_2056.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-25 18:56
|
||||
|
||||
from django.db import migrations
|
||||
from django.contrib.postgres.operations import TrigramExtension, UnaccentExtension
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0229_alter_ailog_options_alter_aiprovider_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
TrigramExtension(),
|
||||
UnaccentExtension(),
|
||||
]
|
||||
@@ -0,0 +1,141 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-30 18:47
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0230_auto_20250925_2056'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='aiprovider',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='automation',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='bookmarkletimport',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='comment',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='connectorconfig',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='cooklog',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='customfilter',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='exportlog',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='food',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='importlog',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='invitelink',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='keyword',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='mealplan',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='mealtype',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='recipe',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='recipebook',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='recipeimport',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='sharelink',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='shoppinglistentry',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='shoppinglistrecipe',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='space',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='storage',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='supermarket',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='supermarketcategory',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='sync',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='synclog',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='telegrambot',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='unit',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='unitconversion',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='userfile',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='userspace',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='viewlog',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
]
|
||||
@@ -402,6 +402,9 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class AiProvider(models.Model):
|
||||
name = models.CharField(max_length=128)
|
||||
@@ -421,7 +424,7 @@ class AiProvider(models.Model):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
ordering = ('id',)
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class AiLog(models.Model, PermissionModelMixin):
|
||||
@@ -476,6 +479,9 @@ class ConnectorConfig(models.Model, PermissionModelMixin):
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class UserPreference(models.Model, PermissionModelMixin):
|
||||
# Themes
|
||||
@@ -579,6 +585,9 @@ class UserSpace(models.Model, PermissionModelMixin):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class Storage(models.Model, PermissionModelMixin):
|
||||
DROPBOX = 'DB'
|
||||
@@ -592,7 +601,7 @@ class Storage(models.Model, PermissionModelMixin):
|
||||
)
|
||||
username = models.CharField(max_length=128, blank=True, null=True)
|
||||
password = models.CharField(max_length=128, blank=True, null=True)
|
||||
token = models.CharField(max_length=512, blank=True, null=True)
|
||||
token = models.CharField(max_length=4098, blank=True, null=True)
|
||||
url = models.URLField(blank=True, null=True)
|
||||
path = models.CharField(blank=True, default='', max_length=256)
|
||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||
@@ -603,6 +612,9 @@ class Storage(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class Sync(models.Model, PermissionModelMixin):
|
||||
storage = models.ForeignKey(Storage, on_delete=models.PROTECT)
|
||||
@@ -618,6 +630,9 @@ class Sync(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return self.path
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class SupermarketCategory(models.Model, PermissionModelMixin, MergeModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
@@ -643,6 +658,7 @@ class SupermarketCategory(models.Model, PermissionModelMixin, MergeModelMixin):
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='smc_unique_name_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='supermarket_category_unique_open_data_slug_per_space')
|
||||
]
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class Supermarket(models.Model, PermissionModelMixin):
|
||||
@@ -662,6 +678,7 @@ class Supermarket(models.Model, PermissionModelMixin):
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='sm_unique_name_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='supermarket_unique_open_data_slug_per_space')
|
||||
]
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class SupermarketCategoryRelation(models.Model, PermissionModelMixin):
|
||||
@@ -693,6 +710,9 @@ class SyncLog(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return f"{self.created_at}:{self.sync} - {self.status}"
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelMixin):
|
||||
if SORT_TREE_BY_NAME:
|
||||
@@ -710,6 +730,7 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='kw_unique_name_per_space')
|
||||
]
|
||||
indexes = (Index(fields=['id', 'name']),)
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin, MergeModelMixin):
|
||||
@@ -741,6 +762,7 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='u_unique_name_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='unit_unique_open_data_slug_per_space')
|
||||
]
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
@@ -874,6 +896,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
Index(fields=['id']),
|
||||
Index(fields=['name']),
|
||||
)
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class UnitConversion(ExportModelOperationsMixin('unit_conversion'), models.Model, PermissionModelMixin):
|
||||
@@ -900,6 +923,7 @@ class UnitConversion(ExportModelOperationsMixin('unit_conversion'), models.Model
|
||||
models.UniqueConstraint(fields=['space', 'base_unit', 'converted_unit', 'food'], name='f_unique_conversion_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='unit_conversion_unique_open_data_slug_per_space')
|
||||
]
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, PermissionModelMixin):
|
||||
@@ -1104,13 +1128,14 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel
|
||||
sub_food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe__in=related_recipes).exclude(recipe=None).values_list('recipe'))
|
||||
return Recipe.objects.filter(Q(id__in=related_recipes.values_list('id')) | sub_step_recipes | sub_food_recipes)
|
||||
|
||||
class Meta():
|
||||
class Meta:
|
||||
indexes = (
|
||||
GinIndex(fields=["name_search_vector"]),
|
||||
GinIndex(fields=["desc_search_vector"]),
|
||||
Index(fields=['id']),
|
||||
Index(fields=['name']),
|
||||
)
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionModelMixin):
|
||||
@@ -1131,6 +1156,9 @@ class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionMod
|
||||
|
||||
def __str__(self):
|
||||
return self.text
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class RecipeImport(models.Model, PermissionModelMixin):
|
||||
@@ -1159,6 +1187,9 @@ class RecipeImport(models.Model, PermissionModelMixin):
|
||||
self.delete()
|
||||
return recipe
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128)
|
||||
@@ -1176,6 +1207,7 @@ class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionMod
|
||||
|
||||
class Meta():
|
||||
indexes = (Index(fields=['name']),)
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, PermissionModelMixin):
|
||||
@@ -1221,6 +1253,7 @@ class MealType(models.Model, PermissionModelMixin):
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name', 'created_by'], name='mt_unique_name_per_space'),
|
||||
]
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, PermissionModelMixin):
|
||||
@@ -1248,6 +1281,9 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
|
||||
def __str__(self):
|
||||
return f'{self.get_label()} - {self.from_date} - {self.meal_type.name}'
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=32, blank=True, default='')
|
||||
@@ -1263,6 +1299,9 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod
|
||||
def __str__(self):
|
||||
return f'Shopping list recipe {self.id} - {self.recipe}'
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin):
|
||||
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries')
|
||||
@@ -1294,6 +1333,9 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
@@ -1309,6 +1351,9 @@ class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, Permissi
|
||||
def __str__(self):
|
||||
return f'{self.recipe} - {self.uuid}'
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
def default_valid_until():
|
||||
return date.today() + timedelta(days=14)
|
||||
@@ -1332,6 +1377,9 @@ class InviteLink(ExportModelOperationsMixin('invite_link'), models.Model, Permis
|
||||
def __str__(self):
|
||||
return f'{self.uuid}'
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class TelegramBot(models.Model, PermissionModelMixin):
|
||||
token = models.CharField(max_length=256)
|
||||
@@ -1346,6 +1394,9 @@ class TelegramBot(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
@@ -1363,7 +1414,7 @@ class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionMo
|
||||
def __str__(self):
|
||||
return self.recipe.name
|
||||
|
||||
class Meta():
|
||||
class Meta:
|
||||
indexes = (
|
||||
Index(fields=['id']),
|
||||
Index(fields=['recipe']),
|
||||
@@ -1372,6 +1423,7 @@ class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionMo
|
||||
Index(fields=['created_by']),
|
||||
Index(fields=['created_by', 'rating']),
|
||||
)
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class ViewLog(ExportModelOperationsMixin('view_log'), models.Model, PermissionModelMixin):
|
||||
@@ -1385,13 +1437,14 @@ class ViewLog(ExportModelOperationsMixin('view_log'), models.Model, PermissionMo
|
||||
def __str__(self):
|
||||
return self.recipe.name
|
||||
|
||||
class Meta():
|
||||
class Meta:
|
||||
indexes = (
|
||||
Index(fields=['recipe']),
|
||||
Index(fields=['-created_at']),
|
||||
Index(fields=['created_by']),
|
||||
Index(fields=['recipe', '-created_at', 'created_by']),
|
||||
)
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class ImportLog(models.Model, PermissionModelMixin):
|
||||
@@ -1412,6 +1465,9 @@ class ImportLog(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return f"{self.created_at}:{self.type}"
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class ExportLog(models.Model, PermissionModelMixin):
|
||||
type = models.CharField(max_length=32)
|
||||
@@ -1432,6 +1488,9 @@ class ExportLog(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return f"{self.created_at}:{self.type}"
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models.Model, PermissionModelMixin):
|
||||
html = models.TextField()
|
||||
@@ -1442,6 +1501,9 @@ class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models
|
||||
objects = ScopedManager(space='space')
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
# field names used to configure search behavior - all data populated during data migration
|
||||
# other option is to use a MultiSelectField from https://github.com/goinnn/django-multiselectfield
|
||||
@@ -1509,6 +1571,9 @@ class UserFile(ExportModelOperationsMixin('user_files'), models.Model, Permissio
|
||||
def __str__(self):
|
||||
return f'{self.name} (#{self.id})'
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class Automation(ExportModelOperationsMixin('automations'), models.Model, PermissionModelMixin):
|
||||
FOOD_ALIAS = 'FOOD_ALIAS'
|
||||
@@ -1555,6 +1620,9 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis
|
||||
objects = ScopedManager(space='space')
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class CustomFilter(models.Model, PermissionModelMixin):
|
||||
RECIPE = 'RECIPE'
|
||||
@@ -1585,3 +1653,4 @@ class CustomFilter(models.Model, PermissionModelMixin):
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='cf_unique_name_per_space')
|
||||
]
|
||||
ordering = ('pk',)
|
||||
|
||||
@@ -185,3 +185,32 @@ def test_unit_conversions(space_1, space_2, u1_s1):
|
||||
assert next(x for x in conversions if x.unit == unit_kg_space_2) is not None
|
||||
assert abs(next(x for x in conversions if x.unit == unit_kg_space_2).amount - Decimal(0.1)) < 0.0001
|
||||
print(conversions)
|
||||
|
||||
def test_conversion_with_zero(space_1, space_2, u1_s1):
|
||||
with scopes_disabled():
|
||||
uch = UnitConversionHelper(space_1)
|
||||
|
||||
unit_gram = Unit.objects.create(name='gram', base_unit='g', space=space_1)
|
||||
unit_fantasy = Unit.objects.create(name='Fantasy Unit', base_unit=None, space=space_1)
|
||||
|
||||
food_1 = Food.objects.create(name='Test Food 1', space=space_1)
|
||||
|
||||
ingredient_food_1_gram = Ingredient.objects.create(
|
||||
food=food_1,
|
||||
unit=unit_gram,
|
||||
amount=100,
|
||||
space=space_1,
|
||||
)
|
||||
|
||||
print('\n----------- TEST BASE CUSTOM CONVERSION - TO CUSTOM CONVERSION ---------------')
|
||||
UnitConversion.objects.create(
|
||||
base_amount=0,
|
||||
base_unit=unit_gram,
|
||||
converted_amount=0,
|
||||
converted_unit=unit_fantasy,
|
||||
space=space_1,
|
||||
created_by=auth.get_user(u1_s1),
|
||||
)
|
||||
conversions = uch.get_conversions(ingredient_food_1_gram)
|
||||
|
||||
assert len(conversions) == 1 # conversion always includes the ingredient, if count is 1 no other conversion was found
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer"><img src="https://badgen.net/badge/icon/discord?icon=discord&label" ></a>
|
||||
<a href="https://hub.docker.com/r/vabene1111/recipes" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/docker/pulls/vabene1111/recipes" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/releases/latest" rel="noopener noreferrer"><img src="https://img.shields.io/github/v/release/vabene1111/recipes" ></a>
|
||||
<a href="https://app.tandoor.dev/accounts/login/?demo" rel="noopener noreferrer"><img src="https://img.shields.io/badge/demo-available-success" ></a>
|
||||
<a href="https://app.tandoor.dev/e/demo-auto-login/" rel="noopener noreferrer"><img src="https://img.shields.io/badge/demo-available-success" ></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://tandoor.dev" target="_blank" rel="noopener noreferrer">Website</a> •
|
||||
<a href="https://docs.tandoor.dev/install/docker/" target="_blank" rel="noopener noreferrer">Installation</a> •
|
||||
<a href="https://docs.tandoor.dev/" target="_blank" rel="noopener noreferrer">Docs</a> •
|
||||
<a href="https://app.tandoor.dev/accounts/login/?demo" target="_blank" rel="noopener noreferrer">Demo</a> •
|
||||
<a href="https://app.tandoor.dev/e/demo-auto-login/" target="_blank" rel="noopener noreferrer">Demo</a> •
|
||||
<a href="https://community.tandoor.dev" target="_blank" rel="noopener noreferrer">Community</a> •
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer">Discord</a>
|
||||
</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
These instructions are inspired from a standard django/gunicorn/postgresql instructions ([for example](https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu-16-04))
|
||||
|
||||
!!! warning
|
||||
Make sure to use at least Python 3.10 (although 3.12 is preferred) or higher, and ensure that `pip` is associated with Python 3. Depending on your system configuration, using `python` or `pip` might default to Python 2. Make sure your machine has at least 2048 MB of memory; otherwise, the `yarn build` process may fail with the error: `FATAL ERROR: Reached heap limit - Allocation failed: JavaScript heap out of memory`.
|
||||
Make sure to use at least Python 3.12 or higher, and ensure that `pip` is associated with Python 3. Depending on your system configuration, using `python` or `pip` might default to Python 2. Make sure your machine has at least 2048 MB of memory; otherwise, the `yarn build` process may fail with the error: `FATAL ERROR: Reached heap limit - Allocation failed: JavaScript heap out of memory`.
|
||||
|
||||
!!! warning
|
||||
These instructions are **not** regularly reviewed and might be outdated.
|
||||
|
||||
@@ -96,12 +96,15 @@ Configuration options for serving related services.
|
||||
|
||||
#### Port
|
||||
|
||||
> default `8080` - options: `1-65535`
|
||||
> default `80` - options: `1-65535`
|
||||
|
||||
Port for gunicorn to bind to. Should not be changed if using docker stack with reverse proxy.
|
||||
!!! warning
|
||||
Changed in version 2.3 to no longer configure the port of gunicorn but the port of the internal nginx
|
||||
|
||||
Port where Tandoor exposes its internal web server.
|
||||
|
||||
```
|
||||
TANDOOR_PORT=8080
|
||||
TANDOOR_PORT=80
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80 ipv6only=on;
|
||||
listen ${TANDOOR_PORT};
|
||||
listen [::]:${TANDOOR_PORT} ipv6only=on;
|
||||
server_name localhost;
|
||||
|
||||
client_max_body_size 128M;
|
||||
@@ -19,12 +19,14 @@ server {
|
||||
# pass requests for dynamic content to gunicorn
|
||||
location / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_pass http://localhost:${TANDOOR_PORT};
|
||||
error_page 502 /errors/http502.html;
|
||||
proxy_pass http://unix:/run/tandoor.sock;
|
||||
|
||||
# disabled for now because it redirects to the error page and not back, also not showing html
|
||||
#error_page 502 /errors/http502.html;
|
||||
}
|
||||
|
||||
location /errors/ {
|
||||
alias /etc/nginx/conf.d/errorpages/;
|
||||
alias /etc/nginx/http.d/errorpages/;
|
||||
internal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,6 +535,7 @@ DJANGO_VITE = {
|
||||
"default": {
|
||||
"dev_mode": False,
|
||||
"static_url_prefix": 'vue3',
|
||||
'manifest_path': os.path.join(BASE_DIR, 'cookbook/static/vue3/manifest.json'),
|
||||
"dev_server_port": 5173,
|
||||
"dev_server_host": os.getenv('DJANGO_VITE_DEV_SERVER_HOST', 'localhost'),
|
||||
},
|
||||
@@ -585,7 +586,7 @@ LANGUAGES = [
|
||||
('it', _('Italian')),
|
||||
('lv', _('Latvian')),
|
||||
('nb', _('Norwegian')),
|
||||
#('nb-NO', _('Norwegian Bokmål')),
|
||||
# ('nb-NO', _('Norwegian Bokmål')),
|
||||
('pl', _('Polish')),
|
||||
('pt', _('Portuguese')),
|
||||
('ru', _('Russian')),
|
||||
@@ -595,8 +596,8 @@ LANGUAGES = [
|
||||
('sv', _('Swedish')),
|
||||
('tr', _('Turkish')),
|
||||
('uk', _('Ukranian')),
|
||||
#('zh-Hant', _('Chinese (Traditional Han script)')),
|
||||
#('zh-Hans', _('Chinese (Simplified Han script)')),
|
||||
# ('zh-Hant', _('Chinese (Traditional Han script)')),
|
||||
# ('zh-Hans', _('Chinese (Simplified Han script)')),
|
||||
]
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
@@ -630,7 +631,6 @@ if os.getenv('S3_ACCESS_KEY', ''):
|
||||
if os.getenv('S3_CUSTOM_DOMAIN', ''):
|
||||
AWS_S3_CUSTOM_DOMAIN = os.getenv('S3_CUSTOM_DOMAIN', '')
|
||||
|
||||
|
||||
MEDIA_URL = os.getenv('MEDIA_URL', '/media/')
|
||||
MEDIA_ROOT = os.getenv('MEDIA_ROOT', os.path.join(BASE_DIR, "mediafiles"))
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<v-card class="mt-1 d-print-none" v-if="useUserPreferenceStore().isAuthenticated" :loading="loading">
|
||||
<v-card-text>
|
||||
<v-textarea :label="$t('Comment')" rows="2" v-model="newCookLog.comment"></v-textarea>
|
||||
<v-textarea :label="$t('Comment')" rows="2" v-model="newCookLog.comment" auto-grow></v-textarea>
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="4">
|
||||
<v-label>{{ $t('Rating') }}</v-label>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<v-row>
|
||||
<v-col>
|
||||
<span v-if="step.name">{{ step.name }}</span>
|
||||
<span v-else-if="step.stepRecipe"><v-icon icon="$recipes"></v-icon> {{ step.stepRecipeData.name }}</span>
|
||||
<span v-else>{{ $t('Step') }} {{ props.stepNumber }}</span>
|
||||
</v-col>
|
||||
<v-col class="text-right">
|
||||
@@ -23,11 +22,12 @@
|
||||
<timer :seconds="step.time != undefined ? step.time*60 : 0" @stop="timerRunning = false" v-if="timerRunning"></timer>
|
||||
<v-card-text v-if="step.ingredients.length > 0 || step.instruction != ''">
|
||||
<v-row>
|
||||
<v-col cols="12" md="6" v-if="step.ingredients.length > 0 && step.showIngredientsTable">
|
||||
<v-col cols="12" md="6" v-if="step.ingredients.length > 0 && (step.showIngredientsTable || step.show_ingredients_table)">
|
||||
<ingredients-table v-model="step.ingredients" :ingredient-factor="ingredientFactor"></ingredients-table>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="markdown-body">
|
||||
<instructions :instructions_html="step.instructionsMarkdown" :ingredient_factor="ingredientFactor" v-if="step.instructionsMarkdown != undefined"></instructions>
|
||||
<instructions :instructions_html="step.instructionsMarkdown" :ingredient_factor="ingredientFactor"
|
||||
v-if="step.instructionsMarkdown != undefined"></instructions>
|
||||
<!-- sub recipes dont have a correct schema, thus they use different variable naming -->
|
||||
<instructions :instructions_html="step.instructions_markdown" :ingredient_factor="ingredientFactor" v-else></instructions>
|
||||
</v-col>
|
||||
@@ -35,7 +35,12 @@
|
||||
</v-card-text>
|
||||
|
||||
<template v-if="step.stepRecipe">
|
||||
<v-card class="ma-2 border-md" prepend-icon="$recipes" :title="step.stepRecipeData.name">
|
||||
<v-card class="ma-2 border-md">
|
||||
<v-card-title>
|
||||
<v-icon icon="$recipes"></v-icon>
|
||||
{{ step.stepRecipeData.name }}
|
||||
<v-btn icon="fa-solid fa-up-right-from-square" size="x-small" :to="{name: 'RecipeViewPage', params: {id: step.stepRecipeData.id}}" target="_blank" variant="plain"></v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text class="mt-1" v-for="(subRecipeStep, subRecipeStepIndex) in step.stepRecipeData.steps" :key="subRecipeStep.id">
|
||||
<step-view v-model="step.stepRecipeData.steps[subRecipeStepIndex]" :step-number="subRecipeStepIndex+1" :ingredientFactor="ingredientFactor"></step-view>
|
||||
</v-card-text>
|
||||
|
||||
@@ -98,10 +98,10 @@ const mergedIngredients = computed(() => {
|
||||
const groupedIngredients = new Map<string, Ingredient>();
|
||||
|
||||
allIngredients.forEach(ingredient => {
|
||||
if (!ingredient.food || !ingredient.unit) return;
|
||||
if (!ingredient.food) return;
|
||||
|
||||
// Create a unique key for food-unit combination
|
||||
const key = `${ingredient.food.id}-${ingredient.unit.id}`;
|
||||
const key = `${ingredient.food.id}-${(ingredient.unit ? ingredient.unit.id : 'no_unit')}`;
|
||||
|
||||
if (groupedIngredients.has(key)) {
|
||||
// If this food-unit combination already exists, sum the amounts
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<!-- TODO label is not showing for some reason, for now in placeholder -->
|
||||
|
||||
<v-label class="mt-2" v-if="props.label">{{ props.label }}</v-label>
|
||||
<v-input :hint="props.hint" persistent-hint :label="props.label" :hide-details="props.hideDetails">
|
||||
<v-input :hint="props.hint" persistent-hint :label="props.label" :hide-details="props.hideDetails" :disabled="props.disabled">
|
||||
<template #prepend v-if="$slots.prepend">
|
||||
<slot name="prepend"></slot>
|
||||
</template>
|
||||
@@ -37,6 +37,7 @@
|
||||
:classes="{
|
||||
dropdown: 'multiselect-dropdown z-3000',
|
||||
containerActive: '',
|
||||
containerDisabled: 'text-disabled'
|
||||
}"
|
||||
>
|
||||
<template #option="{ option }" v-if="props.allowCreate">
|
||||
|
||||
@@ -20,26 +20,38 @@
|
||||
{{ $t('Share') }}
|
||||
<recipe-share-dialog :recipe="props.recipe"></recipe-share-dialog>
|
||||
</v-list-item>
|
||||
<v-list-item @click.stop="duplicateRecipe()" prepend-icon="$copy" :disabled="duplicateLoading">
|
||||
{{ $t('Duplicate') }}
|
||||
<template #append>
|
||||
<v-progress-circular v-if="duplicateLoading" indeterminate size="small"></v-progress-circular>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<!-- TODO when calling print() some timing or whatever issue makes it so the useMediaQuery does not work and the sidebar is still shown -->
|
||||
<!-- <v-list-item prepend-icon="fa-solid fa-print" @click="openPrintView()">-->
|
||||
<!-- {{ $t('Print') }}-->
|
||||
<!-- </v-list-item>-->
|
||||
<!-- <v-list-item prepend-icon="fa-solid fa-print" @click="openPrintView()">-->
|
||||
<!-- {{ $t('Print') }}-->
|
||||
<!-- </v-list-item>-->
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
|
||||
<model-edit-dialog model="MealPlan" :itemDefaults="{recipe: recipe, servings: recipe.servings}" :close-after-create="false" :close-after-save="false" v-model="mealPlanDialog"></model-edit-dialog>
|
||||
<model-edit-dialog model="MealPlan" :itemDefaults="{recipe: recipe, servings: recipe.servings}" :close-after-create="false" :close-after-save="false"
|
||||
v-model="mealPlanDialog"></model-edit-dialog>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {nextTick, PropType, ref} from 'vue'
|
||||
import {Recipe, RecipeFlat, RecipeOverview} from "@/openapi";
|
||||
import {ApiApi, Recipe, RecipeFlat, RecipeOverview} from "@/openapi";
|
||||
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
|
||||
import RecipeShareDialog from "@/components/dialogs/RecipeShareDialog.vue";
|
||||
import AddToShoppingDialog from "@/components/dialogs/AddToShoppingDialog.vue";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import {useRouter} from "vue-router";
|
||||
import {useFileApi} from "@/composables/useFileApi.ts";
|
||||
|
||||
const router = useRouter()
|
||||
const {updateRecipeImage} = useFileApi()
|
||||
|
||||
const props = defineProps({
|
||||
recipe: {type: Object as PropType<Recipe | RecipeOverview>, required: true},
|
||||
@@ -47,11 +59,41 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const mealPlanDialog = ref(false)
|
||||
const duplicateLoading = ref(false)
|
||||
|
||||
function openPrintView() {
|
||||
print()
|
||||
}
|
||||
|
||||
/**
|
||||
* create a duplicate of the recipe by pulling its current data and creating a new recipe with the same data
|
||||
*/
|
||||
function duplicateRecipe() {
|
||||
let api = new ApiApi()
|
||||
duplicateLoading.value = true
|
||||
api.apiRecipeRetrieve({id: props.recipe.id!}).then(originalRecipe => {
|
||||
api.apiRecipeCreate({recipe: originalRecipe}).then(newRecipe => {
|
||||
|
||||
if (originalRecipe.image) {
|
||||
updateRecipeImage(newRecipe.id!, null, originalRecipe.image).then(r => {
|
||||
router.push({name: 'RecipeViewPage', params: {id: newRecipe.id!}})
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
|
||||
duplicateLoading.value = false
|
||||
})
|
||||
} else {
|
||||
router.push({name: 'RecipeViewPage', params: {id: newRecipe.id!}})
|
||||
}
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.CREATE_ERROR, err)
|
||||
duplicateLoading.value = false
|
||||
})
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
duplicateLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<template>
|
||||
|
||||
<v-card variant="outlined">
|
||||
<v-card class="border-sm" variant="flat">
|
||||
<template #title>
|
||||
<v-card-title>
|
||||
<v-chip color="primary">{{ $t('Step') }} {{ props.stepIndex + 1 }}</v-chip>
|
||||
{{ step.name }}
|
||||
{{ $t('Step') }} {{ props.stepIndex + 1 }} {{ step.name }}
|
||||
</v-card-title>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-btn size="small" variant="plain" icon>
|
||||
<v-icon icon="fas fa-sliders-h"></v-icon>
|
||||
<v-btn variant="plain" density="compact" icon>
|
||||
<v-icon icon="$menu"></v-icon>
|
||||
<v-menu activator="parent">
|
||||
<v-list>
|
||||
<v-list-item prepend-icon="fas fa-plus-circle" @click="showName = true" v-if="!showName && (step.name == null || step.name == '')">{{
|
||||
@@ -27,8 +26,8 @@
|
||||
<v-switch v-model="step.showAsHeader" :label="$t('Show_as_header')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item @click="emit('move')" prepend-icon="fa-solid fa-sort">
|
||||
{{ $t('Move') }}
|
||||
</v-list-item>
|
||||
{{ $t('Move') }}
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item prepend-icon="$delete" @click="emit('delete')">{{ $t('Delete') }}</v-list-item>
|
||||
</v-list>
|
||||
@@ -56,64 +55,63 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row dense>
|
||||
<v-row class="mt-2" dense>
|
||||
<v-col cols="12">
|
||||
<v-label>{{ $t('Ingredients') }}</v-label>
|
||||
<div v-if="!mobile">
|
||||
<vue-draggable v-model="step.ingredients" handle=".drag-handle" :on-sort="sortIngredients" :empty-insert-threshold="25" group="ingredients">
|
||||
<v-row v-for="(ingredient, index) in step.ingredients" :key="ingredient.id" dense>
|
||||
<v-col cols="12" class="pa-0 ma-0 text-center text-disabled" v-if="ingredient.originalText">
|
||||
<div v-for="(ingredient, index) in step.ingredients" :key="ingredient.id" dense>
|
||||
<div class="pa-0 ma-0 text-center text-disabled" v-if="ingredient.originalText">
|
||||
<v-icon icon="$import" size="x-small"></v-icon>
|
||||
{{ ingredient.originalText }}
|
||||
</v-col>
|
||||
<v-col cols="2" v-if="!ingredient.isHeader">
|
||||
<v-input hide-details>
|
||||
<template #prepend>
|
||||
<v-icon icon="$dragHandle" class="drag-handle cursor-grab" v-if="ingredient.noAmount" density="compact"></v-icon>
|
||||
</template>
|
||||
</v-input>
|
||||
<v-text-field :id="`id_input_amount_${step.id}_${index}`" :label="$t('Amount')" type="number" v-model="ingredient.amount" density="compact"
|
||||
hide-details v-if="!ingredient.noAmount">
|
||||
</div>
|
||||
<div class="d-flex flex-nowrap">
|
||||
<div class="flex-col flex-grow-0 ma-1" style="min-width: 15%" v-if="!ingredient.isHeader">
|
||||
<v-text-field :id="`id_input_amount_${props.stepIndex}_${index}`" :label="$t('Amount')" type="number" v-model="ingredient.amount" density="compact"
|
||||
hide-details :disabled="ingredient.noAmount">
|
||||
|
||||
<template #prepend>
|
||||
<v-icon icon="$dragHandle" class="drag-handle cursor-grab"></v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="3" v-if="!ingredient.isHeader ">
|
||||
<model-select model="Unit" v-model="ingredient.unit" density="compact" allow-create hide-details v-if="!ingredient.noAmount"></model-select>
|
||||
</v-col>
|
||||
<v-col cols="3" v-if="!ingredient.isHeader">
|
||||
<model-select model="Food" v-model="ingredient.food" density="compact" allow-create hide-details></model-select>
|
||||
</v-col>
|
||||
<v-col :cols="(ingredient.isHeader) ? 11 : 3" @keydown.tab="event => handleIngredientNoteTab(event, index)">
|
||||
<v-text-field :label="(ingredient.isHeader) ? $t('Headline') : $t('Note')" v-model="ingredient.note" density="compact" hide-details>
|
||||
<template #prepend v-if="ingredient.isHeader">
|
||||
<v-icon icon="$dragHandle" class="drag-handle cursor-grab"></v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="1">
|
||||
<v-btn variant="plain" tabindex="-1" icon>
|
||||
<v-icon icon="$settings"></v-icon>
|
||||
<v-menu activator="parent">
|
||||
<v-list>
|
||||
<v-list-item @click="step.ingredients.splice(index, 1)" prepend-icon="$delete">{{ $t('Delete') }}</v-list-item>
|
||||
<v-list-item link>
|
||||
<v-switch v-model="step.ingredients[index].isHeader" :label="$t('Headline')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item link>
|
||||
<v-switch v-model="step.ingredients[index].noAmount" :label="$t('Disable_Amount')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item @click="editingIngredientIndex = index; dialogIngredientSorter = true" prepend-icon="fa-solid fa-sort">
|
||||
{{ $t('Move') }}
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
<template #prepend>
|
||||
<v-icon icon="$dragHandle" class="drag-handle cursor-grab"></v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</div>
|
||||
<div class="flex-col flex-grow-0 ma-1" style="min-width: 15%" v-if="!ingredient.isHeader ">
|
||||
<model-select model="Unit" v-model="ingredient.unit" density="compact" allow-create hide-details :disabled="ingredient.noAmount"></model-select>
|
||||
</div>
|
||||
<div class="flex-col flex-grow-1 ma-1" style="min-width: 15%" v-if="!ingredient.isHeader">
|
||||
<model-select model="Food" v-model="ingredient.food" density="compact" allow-create hide-details></model-select>
|
||||
</div>
|
||||
<div class="flex-col ma-1" style="min-width: 15%" :class="{'flex-grow-1': ingredient.isHeader, 'flex-grow-0': !ingredient.isHeader}"
|
||||
@keydown.tab="event => handleIngredientNoteTab(event, index)">
|
||||
<v-text-field :label="(ingredient.isHeader) ? $t('Headline') : $t('Note')" v-model="ingredient.note" density="compact" hide-details>
|
||||
<template #prepend v-if="ingredient.isHeader">
|
||||
<v-icon icon="$dragHandle" class="drag-handle cursor-grab"></v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</div>
|
||||
<div class="flex-col flex-grow-0 d-flex ma-1">
|
||||
<div class="d-flex align-center justify-center">
|
||||
<v-btn variant="plain" class="" density="compact" tabindex="-1" icon>
|
||||
<v-icon icon="$menu"></v-icon>
|
||||
<v-menu activator="parent">
|
||||
<v-list>
|
||||
<v-list-item link>
|
||||
<v-switch v-model="step.ingredients[index].isHeader" :label="$t('Headline')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item link>
|
||||
<v-switch v-model="step.ingredients[index].noAmount" :label="$t('Disable_Amount')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item @click="editingIngredientIndex = index; dialogIngredientSorter = true" prepend-icon="fa-solid fa-sort">
|
||||
{{ $t('Move') }}
|
||||
</v-list-item>
|
||||
<v-list-item @click="step.ingredients.splice(index, 1)" prepend-icon="$delete">{{ $t('Delete') }}</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</vue-draggable>
|
||||
</div>
|
||||
|
||||
@@ -130,13 +128,11 @@
|
||||
</vue-draggable>
|
||||
</v-list>
|
||||
|
||||
<v-btn-group density="compact" class="mt-1">
|
||||
<v-btn color="success" @click="insertAndFocusIngredient()" prepend-icon="$add">{{ $t('Add') }}</v-btn>
|
||||
<v-btn color="warning" @click="dialogIngredientParser = true">
|
||||
<v-icon icon="$add"></v-icon>
|
||||
<v-icon icon="$add"></v-icon>
|
||||
</v-btn>
|
||||
</v-btn-group>
|
||||
<div class="text-center mt-2">
|
||||
<v-btn icon="$create" variant="outlined" size="x-small" @click="insertAndFocusIngredient()"></v-btn>
|
||||
<v-btn icon="fa-solid fa-clipboard-list" variant="outlined" size="x-small" class="ms-2" @click="dialogIngredientParser = true"></v-btn>
|
||||
</div>
|
||||
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-label>{{ $t('Instructions') }}</v-label>
|
||||
@@ -150,7 +146,7 @@
|
||||
</v-alert>
|
||||
<template v-else>
|
||||
<p>
|
||||
<step-markdown-editor class="h-100" v-model="step"></step-markdown-editor>
|
||||
<step-markdown-editor v-model="step"></step-markdown-editor>
|
||||
</p>
|
||||
</template>
|
||||
</v-col>
|
||||
@@ -240,8 +236,9 @@ import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {ingredientToString} from "@/utils/model_utils";
|
||||
import StepIngredientSorterDialog from "@/components/dialogs/StepIngredientSorterDialog.vue";
|
||||
import {mergeStep} from "@/utils/step_utils.ts";
|
||||
|
||||
const emit = defineEmits(['delete','move'])
|
||||
const emit = defineEmits(['delete', 'move'])
|
||||
|
||||
const step = defineModel<Step>({required: true})
|
||||
const recipe = defineModel<Recipe>('recipe', {required: true})
|
||||
@@ -352,7 +349,7 @@ function insertAndFocusIngredient() {
|
||||
editingIngredientIndex.value = step.value.ingredients.length - 1
|
||||
dialogIngredientEditor.value = true
|
||||
} else {
|
||||
document.getElementById(`id_input_amount_${step.value.id}_${step.value.ingredients.length - 1}`).select()
|
||||
document.getElementById(`id_input_amount_${props.stepIndex}_${step.value.ingredients.length - 1}`).select()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<mavon-editor v-model="step.instruction" :autofocus="false" :external-link="false"
|
||||
style="z-index: auto" :id="'id_instruction_' + step.id"
|
||||
style="z-index: auto; box-shadow: none;" class="border-sm" :id="'id_instruction_' + step.id"
|
||||
:language="'en'"
|
||||
:toolbars="md_editor_toolbars" :defaultOpen="'edit'" ref="markdownEditor">
|
||||
<template #left-toolbar-after>
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
<v-rating v-model="editingObj.rating" clearable hover density="compact"></v-rating>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
|
||||
<v-number-input :label="$t('Servings')" v-model="editingObj.servings" :precision="2"></v-number-input>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
@@ -42,7 +41,7 @@ import {onMounted, PropType, watch} from "vue";
|
||||
import {CookLog} from "@/openapi";
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
|
||||
import {VDateInput} from 'vuetify/labs/VDateInput' //TODO remove once component is out of labs
|
||||
|
||||
const props = defineProps({
|
||||
item: {type: {} as PropType<CookLog>, required: false, default: null},
|
||||
|
||||
@@ -70,31 +70,37 @@
|
||||
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="steps">
|
||||
<v-row>
|
||||
<v-col >
|
||||
<v-btn-group density="compact" divided border>
|
||||
|
||||
<v-btn prepend-icon="fa-solid fa-maximize" @click="handleSplitAllSteps" :disabled="editingObj.steps.length < 1"><span
|
||||
v-if="!mobile">{{ $t('Split') }}</span></v-btn>
|
||||
<v-btn prepend-icon="fa-solid fa-minimize" @click="editingObj.steps = handleMergeAllSteps()" :disabled="editingObj.steps.length < 2"><span
|
||||
v-if="!mobile">{{ $t('Merge') }}</span></v-btn>
|
||||
<ai-action-button :text="$t('Auto_Sort')" prepend-icon="$ai" :loading="aiStepSortLoading" @selected="aiStepSort"
|
||||
:disabled="editingObj.steps.length < 1"></ai-action-button>
|
||||
</v-btn-group>
|
||||
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-form :disabled="loading || fileApiLoading">
|
||||
<v-row v-for="(s,i ) in editingObj.steps" :key="s.id">
|
||||
<v-row v-for="(s,i ) in editingObj.steps" :key="s.id" dense>
|
||||
<v-col>
|
||||
<step-editor v-model="editingObj.steps[i]" v-model:recipe="editingObj" :step-index="i" @delete="deleteStepAtIndex(i)" @move="dialogStepManager = true"></step-editor>
|
||||
|
||||
<div class="text-center mt-2">
|
||||
<v-btn icon="$create" variant="outlined" size="x-small" @click="addStep(i+1)"></v-btn>
|
||||
<v-btn icon="fa-solid fa-down-left-and-up-right-to-center" style="transform: rotate(135deg)" variant="outlined" size="x-small" class="ms-2"
|
||||
@click="mergeStep(s, editingObj.steps[i+1]); editingObj.steps.splice(i+1,1)" v-if="editingObj.steps.length > i + 1"
|
||||
></v-btn>
|
||||
<v-btn icon="fa-solid fa-arrow-down-1-9" variant="outlined" size="x-small" class="ms-2" @click="dialogStepManager = true"
|
||||
:disabled="editingObj.steps.length < 2"></v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col class="text-center">
|
||||
<v-btn-group density="compact" divided border>
|
||||
<v-btn color="success" prepend-icon="fa-solid fa-plus" @click="addStep()">{{ $t('Add_Step') }}</v-btn>
|
||||
<v-btn color="warning" @click="dialogStepManager = true" :disabled="editingObj.steps.length < 2">
|
||||
<v-icon icon="fa-solid fa-arrow-down-1-9"></v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn prepend-icon="fa-solid fa-maximize" @click="handleSplitAllSteps" :disabled="editingObj.steps.length < 1"><span
|
||||
v-if="!mobile">{{ $t('Split') }}</span></v-btn>
|
||||
<v-btn prepend-icon="fa-solid fa-minimize" @click="handleMergeAllSteps" :disabled="editingObj.steps.length < 2"><span
|
||||
v-if="!mobile">{{ $t('Merge') }}</span></v-btn>
|
||||
<ai-action-button :text="$t('Auto_Sort')" prepend-icon="$ai" :loading="aiStepSortLoading" @selected="aiStepSort"></ai-action-button>
|
||||
</v-btn-group>
|
||||
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-form>
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="properties">
|
||||
@@ -171,7 +177,7 @@ import ClosableHelpAlert from "@/components/display/ClosableHelpAlert.vue";
|
||||
import {useDisplay} from "vuetify";
|
||||
import {isSpaceAtRecipeLimit} from "@/utils/logic_utils";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import {mergeAllSteps, splitAllSteps} from "@/utils/step_utils.ts";
|
||||
import {mergeAllSteps, mergeStep, splitAllSteps} from "@/utils/step_utils.ts";
|
||||
import DeleteConfirmDialog from "@/components/dialogs/DeleteConfirmDialog.vue";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import AiActionButton from "@/components/buttons/AiActionButton.vue";
|
||||
@@ -217,6 +223,12 @@ function initializeEditor() {
|
||||
setupState(props.item, props.itemId, {
|
||||
newItemFunction: () => {
|
||||
editingObj.value.steps = [] as Step[]
|
||||
addStep()
|
||||
editingObj.value.steps[0].ingredients.push({
|
||||
food: null,
|
||||
unit: null,
|
||||
amount: 0,
|
||||
} as Ingredient)
|
||||
editingObj.value.internal = true //TODO make database default after v2
|
||||
},
|
||||
itemDefaults: props.itemDefaults,
|
||||
@@ -248,13 +260,20 @@ function deleteImage() {
|
||||
|
||||
/**
|
||||
* add a new step to the recipe
|
||||
* @param index index to add at, -1 for end
|
||||
*/
|
||||
function addStep() {
|
||||
editingObj.value.steps.push({
|
||||
function addStep(index: number = -1) {
|
||||
let newStep = {
|
||||
ingredients: [] as Ingredient[],
|
||||
time: 0,
|
||||
showIngredientsTable: useUserPreferenceStore().userSettings.showStepIngredients
|
||||
} as Step)
|
||||
} as Step
|
||||
|
||||
if (index >= 0) {
|
||||
editingObj.value.steps.splice(index, 0, newStep)
|
||||
} else {
|
||||
editingObj.value.steps.push(newStep)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,10 +293,11 @@ function deleteStepAtIndex(index: number) {
|
||||
editingObj.value.steps.splice(index, 1)
|
||||
}
|
||||
|
||||
function handleMergeAllSteps(): void {
|
||||
function handleMergeAllSteps() {
|
||||
if (editingObj.value.steps) {
|
||||
mergeAllSteps(editingObj.value.steps)
|
||||
return mergeAllSteps(editingObj.value.steps)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function handleSplitAllSteps(): void {
|
||||
@@ -305,7 +325,7 @@ function deleteExternalFile() {
|
||||
* sort steps and ingredients using UI and update recipe with result
|
||||
* @param providerId provider to use for request
|
||||
*/
|
||||
function aiStepSort(providerId: number){
|
||||
function aiStepSort(providerId: number) {
|
||||
let api = new ApiApi()
|
||||
aiStepSortLoading.value = true
|
||||
api.apiAiStepSortCreate({recipe: editingObj.value, provider: providerId}).then(r => {
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col md="6">
|
||||
<v-number-input :label="$t('Amount')" :step="10" v-model="editingObj.baseAmount" control-variant="stacked" :precision="3"></v-number-input>
|
||||
<v-number-input :label="$t('Amount')" :step="10" v-model="editingObj.baseAmount" control-variant="stacked" :precision="3" :min="0.001"></v-number-input>
|
||||
</v-col>
|
||||
<v-col md="6">
|
||||
<!-- TODO fix card overflow invisible, overflow-visible class is not working -->
|
||||
<model-select :label="$t('Unit')" v-model="editingObj.baseUnit" model="Unit"></model-select>
|
||||
<model-select v-model="editingObj.baseUnit" model="Unit"></model-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row class="mt-0">
|
||||
@@ -33,11 +33,11 @@
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col md="6">
|
||||
<v-number-input :label="$t('Amount')" :step="10" v-model="editingObj.convertedAmount" control-variant="stacked" :precision="3"></v-number-input>
|
||||
<v-number-input :label="$t('Amount')" :step="10" v-model="editingObj.convertedAmount" control-variant="stacked" :precision="3" :min="0.001"></v-number-input>
|
||||
</v-col>
|
||||
<v-col md="6">
|
||||
<!-- TODO fix card overflow invisible, overflow-visible class is not working -->
|
||||
<model-select :label="$t('Unit')" v-model="editingObj.convertedUnit" model="Unit"></model-select>
|
||||
<model-select v-model="editingObj.convertedUnit" model="Unit"></model-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"Fats": "",
|
||||
"File": "",
|
||||
"Files": "",
|
||||
"Finish": "",
|
||||
"Food": "",
|
||||
"FoodInherit": "",
|
||||
"FoodNotOnHand": "",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"Fats": "Мазнини",
|
||||
"File": "Файл",
|
||||
"Files": "Файлове",
|
||||
"Finish": "",
|
||||
"Food": "Храна",
|
||||
"FoodInherit": "Хранителни наследствени полета",
|
||||
"FoodNotOnHand": "Нямате {храна} под ръка.",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "Greixos",
|
||||
"File": "Arxiu",
|
||||
"Files": "Arxius",
|
||||
"Finish": "",
|
||||
"First_name": "Nom",
|
||||
"Food": "Aliment",
|
||||
"FoodInherit": "Camps Heretats",
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"Fats": "Tuky",
|
||||
"File": "Soubor",
|
||||
"Files": "Soubory",
|
||||
"Finish": "",
|
||||
"First_name": "Jméno",
|
||||
"Food": "Potravina",
|
||||
"FoodInherit": "Propisovatelná pole potraviny",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "Fedtstoffer",
|
||||
"File": "Fil",
|
||||
"Files": "Filer",
|
||||
"Finish": "",
|
||||
"First_name": "Fornavn",
|
||||
"Food": "Mad",
|
||||
"FoodInherit": "Nedarvelige mad felter",
|
||||
|
||||
@@ -212,6 +212,7 @@
|
||||
"Fats": "Fette",
|
||||
"File": "Datei",
|
||||
"Files": "Dateien",
|
||||
"Finish": "Fertigstellen",
|
||||
"FinishedAt": "Fertig um",
|
||||
"First": "Erstes",
|
||||
"First_name": "Vorname",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "Λιπαρά",
|
||||
"File": "Αρχείο",
|
||||
"Files": "Αρχεία",
|
||||
"Finish": "",
|
||||
"First_name": "Όνομα",
|
||||
"Food": "Φαγητό",
|
||||
"FoodInherit": "Πεδία φαγητών που κληρονομούνται",
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"Fats": "Fats",
|
||||
"File": "File",
|
||||
"Files": "Files",
|
||||
"Finish": "Finish",
|
||||
"FinishedAt": "Finished at",
|
||||
"First": "First",
|
||||
"First_name": "First Name",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"Fats": "Grasas",
|
||||
"File": "Archivo",
|
||||
"Files": "Archivos",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Finaliza a las",
|
||||
"First": "Primero",
|
||||
"First_name": "Nombre",
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
"Fats": "Rasvat",
|
||||
"File": "Tiedosto",
|
||||
"Files": "Tiedostot",
|
||||
"Finish": "",
|
||||
"First_name": "Etunimi",
|
||||
"Food": "Ruoka",
|
||||
"FoodInherit": "Ruoan perinnölliset kentät",
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"Fats": "Matières grasses",
|
||||
"File": "Fichier",
|
||||
"Files": "Fichiers",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Terminé à",
|
||||
"First": "Premier",
|
||||
"First_name": "Prénom",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "שומנים",
|
||||
"File": "קובץ",
|
||||
"Files": "קבצים",
|
||||
"Finish": "",
|
||||
"First_name": "שם פרטי",
|
||||
"Food": "אוכל",
|
||||
"FoodInherit": "ערכי מזון",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "Masti",
|
||||
"File": "Datoteka",
|
||||
"Files": "Datoteke",
|
||||
"Finish": "",
|
||||
"First_name": "Ime",
|
||||
"Food": "Namirnica",
|
||||
"FoodInherit": "Nasljedna polja namirnice",
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
"Fats": "Zsírok",
|
||||
"File": "Fájl",
|
||||
"Files": "Fájlok",
|
||||
"Finish": "",
|
||||
"First_name": "Keresztnév",
|
||||
"Food": "Alapanyag",
|
||||
"FoodInherit": "",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"Fats": "",
|
||||
"File": "",
|
||||
"Files": "",
|
||||
"Finish": "",
|
||||
"Food": "Սննդամթերք",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "",
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"Fats": "Lemak",
|
||||
"File": "Berkas",
|
||||
"Files": "File",
|
||||
"Finish": "",
|
||||
"First_name": "",
|
||||
"Food": "",
|
||||
"FoodInherit": "",
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"Fats": "",
|
||||
"File": "",
|
||||
"Files": "",
|
||||
"Finish": "",
|
||||
"First_name": "",
|
||||
"Food": "",
|
||||
"FoodInherit": "",
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
"Fats": "Grassi",
|
||||
"File": "File",
|
||||
"Files": "File",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Finito alle",
|
||||
"First": "Primo",
|
||||
"First_name": "Nome",
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"Fats": "",
|
||||
"File": "",
|
||||
"Files": "",
|
||||
"Finish": "",
|
||||
"First_name": "",
|
||||
"Food": "",
|
||||
"FoodInherit": "",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "",
|
||||
"File": "",
|
||||
"Files": "",
|
||||
"Finish": "",
|
||||
"First_name": "",
|
||||
"Food": "",
|
||||
"FoodInherit": "",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"Fats": "Fett",
|
||||
"File": "Fil",
|
||||
"Files": "Filer",
|
||||
"Finish": "",
|
||||
"First_name": "Fornavn",
|
||||
"Food": "Matretter",
|
||||
"FoodInherit": "Arvbare felt for matvarer",
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
"Fats": "Vetten",
|
||||
"File": "Bestand",
|
||||
"Files": "Bestanden",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Afgerond op",
|
||||
"First": "Eerste",
|
||||
"First_name": "Voornaam",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"Fats": "Tłuszcze",
|
||||
"File": "Plik",
|
||||
"Files": "Pliki",
|
||||
"Finish": "",
|
||||
"First_name": "Imię",
|
||||
"Food": "Żywność",
|
||||
"FoodInherit": "Pola dziedziczone w żywności",
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"Fats": "Gorduras",
|
||||
"File": "Ficheiro",
|
||||
"Files": "Ficheiros",
|
||||
"Finish": "",
|
||||
"Food": "Comida",
|
||||
"FoodInherit": "Campos herdados por comida",
|
||||
"FoodNotOnHand": "Não têm {food} disponível.",
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
"Fats": "Gorduras",
|
||||
"File": "Arquivo",
|
||||
"Files": "Arquivos",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Finalizado em",
|
||||
"First": "Primeiro",
|
||||
"First_name": "Primeiro Nome",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"Fats": "Grăsimi",
|
||||
"File": "Fișier",
|
||||
"Files": "Fișiere",
|
||||
"Finish": "",
|
||||
"First_name": "Prenume",
|
||||
"Food": "Mâncare",
|
||||
"FoodInherit": "Câmpuri moștenite de alimente",
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"Fats": "Жиры",
|
||||
"File": "Файл",
|
||||
"Files": "Файлы",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Завершено в",
|
||||
"First": "Первый",
|
||||
"First_name": "Имя",
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"Fats": "Maščobe",
|
||||
"File": "Datoteka",
|
||||
"Files": "Datoteke",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Končano ob",
|
||||
"First": "Prvi",
|
||||
"First_name": "Ime",
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
"Fats": "Fett",
|
||||
"File": "Fil",
|
||||
"Files": "Filer",
|
||||
"Finish": "",
|
||||
"First_name": "Förnamn",
|
||||
"Food": "Livsmedel",
|
||||
"FoodInherit": "Ärftliga livsmedels fält",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "Yağlar",
|
||||
"File": "Dosya",
|
||||
"Files": "Dosyalar",
|
||||
"Finish": "",
|
||||
"First_name": "İsim",
|
||||
"Food": "Yiyecek",
|
||||
"FoodInherit": "Yiyeceğin Devralınabileceği Alanlar",
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
"Fats": "Жири",
|
||||
"File": "Файл",
|
||||
"Files": "Файли",
|
||||
"Finish": "",
|
||||
"Food": "Їжа",
|
||||
"FoodInherit": "Пола Успадкованої Їжі",
|
||||
"FoodNotOnHand": "У вас немає {food} на руках.",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "脂肪",
|
||||
"File": "文件",
|
||||
"Files": "文件",
|
||||
"Finish": "",
|
||||
"First_name": "名",
|
||||
"Food": "食物",
|
||||
"FoodInherit": "食物可继承的字段",
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
"Fats": "脂肪",
|
||||
"File": "檔案",
|
||||
"Files": "檔案",
|
||||
"Finish": "",
|
||||
"FinishedAt": "完成於",
|
||||
"First": "第一個",
|
||||
"First_name": "名字",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</v-list-item>
|
||||
<v-list-item link prepend-icon="fa-solid fa-arrows-to-dot" :disabled="!selectedFood">
|
||||
{{ $t('Merge') }}
|
||||
<model-merge-dialog :source="selectedFood" model="Food"
|
||||
<model-merge-dialog :source="[selectedFood]" model="Food"
|
||||
@change="(obj: Food) => {selectedFood = obj;refreshPage()} "></model-merge-dialog>
|
||||
</v-list-item>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</v-list-item>
|
||||
<v-list-item link prepend-icon="fa-solid fa-arrows-to-dot" :disabled="!selectedUnit">
|
||||
{{ $t('Merge') }}
|
||||
<model-merge-dialog :source="selectedUnit" model="Unit"
|
||||
<model-merge-dialog :source="[selectedUnit]" model="Unit"
|
||||
@change="(obj: Food) => {selectedUnit = obj;refreshPage()} "></model-merge-dialog>
|
||||
</v-list-item>
|
||||
<v-list-item link prepend-icon="$automation" :disabled="!selectedUnit">
|
||||
@@ -117,12 +117,12 @@
|
||||
@update:modelValue="item.changed = true" :precision="2"></v-number-input>
|
||||
</template>
|
||||
<template v-slot:item.unit="{ item }">
|
||||
<model-select model="Unit" v-model="item.unit" :label="$t('Unit')" density="compact" hide-details allow-create append-to-body
|
||||
<model-select model="Unit" v-model="item.unit" density="compact" hide-details allow-create append-to-body
|
||||
@update:modelValue="item.changed = true">
|
||||
</model-select>
|
||||
</template>
|
||||
<template v-slot:item.food="{ item }">
|
||||
<model-select model="Food" v-model="item.food" :label="$t('Food')" density="compact" hide-details allow-create append-to-body
|
||||
<model-select model="Food" v-model="item.food" density="compact" hide-details allow-create append-to-body
|
||||
@update:modelValue="item.changed = true"></model-select>
|
||||
</template>
|
||||
<template v-slot:item.note="{ item }">
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
|
||||
</v-number-input>
|
||||
|
||||
<v-btn variant="outlined" color="create" block v-if="p.propertyAmount == null" @click="p.propertyAmount = 0">
|
||||
<v-btn variant="outlined" color="create" block v-if="p.propertyAmount == null" @click="p.propertyAmount = 0; updateFood(ingredient)">
|
||||
<v-icon icon="$create"></v-icon>
|
||||
</v-btn>
|
||||
</td>
|
||||
|
||||
@@ -837,7 +837,7 @@ function deleteStep(step: SourceImportStep) {
|
||||
|
||||
function handleMergeAllSteps(): void {
|
||||
if (importResponse.value.recipe && importResponse.value.recipe.steps) {
|
||||
mergeAllSteps(importResponse.value.recipe.steps)
|
||||
importResponse.value.recipe.steps = mergeAllSteps(importResponse.value.recipe.steps)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,7 +931,10 @@ function setAllKeywordsImportStatus(status: boolean) {
|
||||
* add a new (empty) step at the end of the step list
|
||||
*/
|
||||
function addStep() {
|
||||
importResponse.value.recipe?.steps.push({} as SourceImportStep)
|
||||
importResponse.value.recipe?.steps.push({
|
||||
ingredients: [],
|
||||
instruction: ''
|
||||
} as SourceImportStep)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -788,7 +788,7 @@ const filters = ref({
|
||||
enabled: false,
|
||||
default: undefined,
|
||||
is: VNumberInput,
|
||||
modelValue: useRouteQuery('timescookedGte', undefined, {transform: Number}),
|
||||
modelValue: useRouteQuery('timescooked', undefined, {transform: Number}),
|
||||
},
|
||||
timescookedGte: {
|
||||
id: 'timescookedGte',
|
||||
|
||||
@@ -35,9 +35,9 @@
|
||||
</v-card>
|
||||
<template v-if="totalRecipes > 0">
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="recent" v-if="totalRecipes > 5"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="new"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="new" v-if="totalRecipes > 1"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="keyword" v-if="totalRecipes > 10"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="random" v-if="totalRecipes > 10"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="random" v-if="totalRecipes > 1"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="created_by" v-if="totalRecipes > 5"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="2" mode="rating" v-if="totalRecipes > 5"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="keyword" v-if="totalRecipes > 5"></horizontal-recipe-scroller>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {MessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import {Step} from "@/openapi";
|
||||
import {SourceImportStep, Step} from "@/openapi";
|
||||
|
||||
interface StepLike {
|
||||
instruction?: string;
|
||||
ingredients?: Array<any>;
|
||||
showIngredientsTable?: boolean;
|
||||
instruction?: string;
|
||||
ingredients?: Array<any>;
|
||||
showIngredientsTable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,7 +15,7 @@ interface StepLike {
|
||||
*/
|
||||
function splitStepObject<T extends StepLike>(step: T, split_character: string) {
|
||||
let steps: T[] = []
|
||||
if (step.instruction){
|
||||
if (step.instruction) {
|
||||
step.instruction.split(split_character).forEach(part => {
|
||||
if (part.trim() !== '') {
|
||||
steps.push({instruction: part, ingredients: [], time: 0, showIngredientsTable: useUserPreferenceStore().userSettings.showStepIngredients!})
|
||||
@@ -49,7 +49,7 @@ export function splitAllSteps<T extends StepLike>(orig_steps: T[], split_charact
|
||||
* @param split_character character to use as a delimiter between steps
|
||||
*/
|
||||
export function splitStep<T extends StepLike>(steps: T[], step: T, split_character: string) {
|
||||
if (steps){
|
||||
if (steps) {
|
||||
let old_index = steps.findIndex(x => x === step)
|
||||
let new_steps = splitStepObject(step, split_character)
|
||||
steps.splice(old_index, 1, ...new_steps)
|
||||
@@ -59,15 +59,29 @@ export function splitStep<T extends StepLike>(steps: T[], step: T, split_charact
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all steps of a given recipe_json into one
|
||||
* merge two given steps into the first one and return it
|
||||
* @param step1
|
||||
* @param step2
|
||||
*/
|
||||
export function mergeAllSteps<T extends StepLike>(steps: T[]) {
|
||||
let step = {instruction: '', ingredients: [], showIngredientsTable: useUserPreferenceStore().userSettings.showStepIngredients!} as T
|
||||
if (steps) {
|
||||
step.instruction = steps.map(s => s.instruction).join('\n')
|
||||
step.ingredients = steps.flatMap(s => s.ingredients)
|
||||
steps.splice(0, steps.length, step) // replace all steps with the merged step
|
||||
export function mergeStep(step1: Step, step2: Step) {
|
||||
if (step2.instruction){
|
||||
step1.instruction = step1.instruction + '\n' + step2.instruction
|
||||
}
|
||||
step1.ingredients = step1.ingredients.concat(step2.ingredients)
|
||||
return step1
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all steps of a given steps array into one
|
||||
*/
|
||||
export function mergeAllSteps(steps: Step[] | SourceImportStep[]) {
|
||||
if (steps.length > 1) {
|
||||
steps[0].instruction = steps.map(s => s.instruction).join('\n')
|
||||
steps[0].ingredients = steps.flatMap(s => s.ingredients)
|
||||
steps = [steps[0]]
|
||||
} else {
|
||||
useMessageStore().addMessage(MessageType.ERROR, "no steps found to split")
|
||||
}
|
||||
|
||||
return steps
|
||||
}
|
||||
Reference in New Issue
Block a user