Merge branch 'develop' into beta

This commit is contained in:
vabene1111
2025-09-30 21:51:53 +02:00
66 changed files with 611 additions and 191 deletions

View File

@@ -30,9 +30,11 @@
![Preview](docs/preview.png)
## 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
View File

@@ -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

View File

@@ -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:

View File

@@ -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,

View File

@@ -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

View File

@@ -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']]

View File

@@ -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',

View File

@@ -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),
),
]

View 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(),
]

View File

@@ -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',)},
),
]

View File

@@ -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',)

View File

@@ -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

View File

@@ -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>

View File

@@ -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.

View File

@@ -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
```

View File

@@ -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;
}
}

View File

@@ -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"))

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View File

@@ -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()
}
})
}

View File

@@ -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>

View File

@@ -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},

View File

@@ -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 => {

View File

@@ -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>

View File

@@ -115,6 +115,7 @@
"Fats": "",
"File": "",
"Files": "",
"Finish": "",
"Food": "",
"FoodInherit": "",
"FoodNotOnHand": "",

View File

@@ -112,6 +112,7 @@
"Fats": "Мазнини",
"File": "Файл",
"Files": "Файлове",
"Finish": "",
"Food": "Храна",
"FoodInherit": "Хранителни наследствени полета",
"FoodNotOnHand": "Нямате {храна} под ръка.",

View File

@@ -154,6 +154,7 @@
"Fats": "Greixos",
"File": "Arxiu",
"Files": "Arxius",
"Finish": "",
"First_name": "Nom",
"Food": "Aliment",
"FoodInherit": "Camps Heretats",

View File

@@ -153,6 +153,7 @@
"Fats": "Tuky",
"File": "Soubor",
"Files": "Soubory",
"Finish": "",
"First_name": "Jméno",
"Food": "Potravina",
"FoodInherit": "Propisovatelná pole potraviny",

View File

@@ -154,6 +154,7 @@
"Fats": "Fedtstoffer",
"File": "Fil",
"Files": "Filer",
"Finish": "",
"First_name": "Fornavn",
"Food": "Mad",
"FoodInherit": "Nedarvelige mad felter",

View File

@@ -212,6 +212,7 @@
"Fats": "Fette",
"File": "Datei",
"Files": "Dateien",
"Finish": "Fertigstellen",
"FinishedAt": "Fertig um",
"First": "Erstes",
"First_name": "Vorname",

View File

@@ -154,6 +154,7 @@
"Fats": "Λιπαρά",
"File": "Αρχείο",
"Files": "Αρχεία",
"Finish": "",
"First_name": "Όνομα",
"Food": "Φαγητό",
"FoodInherit": "Πεδία φαγητών που κληρονομούνται",

View File

@@ -210,6 +210,7 @@
"Fats": "Fats",
"File": "File",
"Files": "Files",
"Finish": "Finish",
"FinishedAt": "Finished at",
"First": "First",
"First_name": "First Name",

View File

@@ -207,6 +207,7 @@
"Fats": "Grasas",
"File": "Archivo",
"Files": "Archivos",
"Finish": "",
"FinishedAt": "Finaliza a las",
"First": "Primero",
"First_name": "Nombre",

View File

@@ -151,6 +151,7 @@
"Fats": "Rasvat",
"File": "Tiedosto",
"Files": "Tiedostot",
"Finish": "",
"First_name": "Etunimi",
"Food": "Ruoka",
"FoodInherit": "Ruoan perinnölliset kentät",

View File

@@ -210,6 +210,7 @@
"Fats": "Matières grasses",
"File": "Fichier",
"Files": "Fichiers",
"Finish": "",
"FinishedAt": "Terminé à",
"First": "Premier",
"First_name": "Prénom",

View File

@@ -154,6 +154,7 @@
"Fats": "שומנים",
"File": "קובץ",
"Files": "קבצים",
"Finish": "",
"First_name": "שם פרטי",
"Food": "אוכל",
"FoodInherit": "ערכי מזון",

View File

@@ -154,6 +154,7 @@
"Fats": "Masti",
"File": "Datoteka",
"Files": "Datoteke",
"Finish": "",
"First_name": "Ime",
"Food": "Namirnica",
"FoodInherit": "Nasljedna polja namirnice",

View File

@@ -137,6 +137,7 @@
"Fats": "Zsírok",
"File": "Fájl",
"Files": "Fájlok",
"Finish": "",
"First_name": "Keresztnév",
"Food": "Alapanyag",
"FoodInherit": "",

View File

@@ -69,6 +69,7 @@
"Fats": "",
"File": "",
"Files": "",
"Finish": "",
"Food": "Սննդամթերք",
"FromBalance": "",
"Fulltext": "",

View File

@@ -126,6 +126,7 @@
"Fats": "Lemak",
"File": "Berkas",
"Files": "File",
"Finish": "",
"First_name": "",
"Food": "",
"FoodInherit": "",

View File

@@ -153,6 +153,7 @@
"Fats": "",
"File": "",
"Files": "",
"Finish": "",
"First_name": "",
"Food": "",
"FoodInherit": "",

View File

@@ -211,6 +211,7 @@
"Fats": "Grassi",
"File": "File",
"Files": "File",
"Finish": "",
"FinishedAt": "Finito alle",
"First": "Primo",
"First_name": "Nome",

View File

@@ -139,6 +139,7 @@
"Fats": "",
"File": "",
"Files": "",
"Finish": "",
"First_name": "",
"Food": "",
"FoodInherit": "",

View File

@@ -154,6 +154,7 @@
"Fats": "",
"File": "",
"Files": "",
"Finish": "",
"First_name": "",
"Food": "",
"FoodInherit": "",

View File

@@ -146,6 +146,7 @@
"Fats": "Fett",
"File": "Fil",
"Files": "Filer",
"Finish": "",
"First_name": "Fornavn",
"Food": "Matretter",
"FoodInherit": "Arvbare felt for matvarer",

View File

@@ -211,6 +211,7 @@
"Fats": "Vetten",
"File": "Bestand",
"Files": "Bestanden",
"Finish": "",
"FinishedAt": "Afgerond op",
"First": "Eerste",
"First_name": "Voornaam",

View File

@@ -180,6 +180,7 @@
"Fats": "Tłuszcze",
"File": "Plik",
"Files": "Pliki",
"Finish": "",
"First_name": "Imię",
"Food": "Żywność",
"FoodInherit": "Pola dziedziczone w żywności",

View File

@@ -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.",

View File

@@ -209,6 +209,7 @@
"Fats": "Gorduras",
"File": "Arquivo",
"Files": "Arquivos",
"Finish": "",
"FinishedAt": "Finalizado em",
"First": "Primeiro",
"First_name": "Primeiro Nome",

View File

@@ -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",

View File

@@ -210,6 +210,7 @@
"Fats": "Жиры",
"File": "Файл",
"Files": "Файлы",
"Finish": "",
"FinishedAt": "Завершено в",
"First": "Первый",
"First_name": "Имя",

View File

@@ -210,6 +210,7 @@
"Fats": "Maščobe",
"File": "Datoteka",
"Files": "Datoteke",
"Finish": "",
"FinishedAt": "Končano ob",
"First": "Prvi",
"First_name": "Ime",

View File

@@ -191,6 +191,7 @@
"Fats": "Fett",
"File": "Fil",
"Files": "Filer",
"Finish": "",
"First_name": "Förnamn",
"Food": "Livsmedel",
"FoodInherit": "Ärftliga livsmedels fält",

View File

@@ -154,6 +154,7 @@
"Fats": "Yağlar",
"File": "Dosya",
"Files": "Dosyalar",
"Finish": "",
"First_name": "İsim",
"Food": "Yiyecek",
"FoodInherit": "Yiyeceğin Devralınabileceği Alanlar",

View File

@@ -137,6 +137,7 @@
"Fats": "Жири",
"File": "Файл",
"Files": "Файли",
"Finish": "",
"Food": "Їжа",
"FoodInherit": "Пола Успадкованої Їжі",
"FoodNotOnHand": "У вас немає {food} на руках.",

View File

@@ -154,6 +154,7 @@
"Fats": "脂肪",
"File": "文件",
"Files": "文件",
"Finish": "",
"First_name": "名",
"Food": "食物",
"FoodInherit": "食物可继承的字段",

View File

@@ -209,6 +209,7 @@
"Fats": "脂肪",
"File": "檔案",
"Files": "檔案",
"Finish": "",
"FinishedAt": "完成於",
"First": "第一個",
"First_name": "名字",

View File

@@ -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 }">

View File

@@ -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>

View File

@@ -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)
}

View File

@@ -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',

View File

@@ -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>

View File

@@ -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
}