mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-26 11:49:41 -05:00
Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cffe116145 | ||
|
|
65eb80dbe6 | ||
|
|
3b946e512c | ||
|
|
d2a6409381 | ||
|
|
262a1f0064 | ||
|
|
fd026154d8 | ||
|
|
7f427c2d1f | ||
|
|
4fe5290b15 | ||
|
|
d45e3b8e60 | ||
|
|
a3fa01d8d3 | ||
|
|
9a746b5397 | ||
|
|
ba3c0b933c | ||
|
|
87164e894a | ||
|
|
d01cb26c4a | ||
|
|
3501bcadb1 | ||
|
|
1cf4f9cb4d | ||
|
|
be24ee7922 | ||
|
|
5e2c3d6ad2 | ||
|
|
129bf16e8c | ||
|
|
ec97b1edae | ||
|
|
16a0ea07c7 | ||
|
|
3ba70683d9 | ||
|
|
f07f3e183d | ||
|
|
5d75220312 | ||
|
|
c136319719 | ||
|
|
c75b666b17 | ||
|
|
fdc0dfaa15 | ||
|
|
7f84186b5b | ||
|
|
bc72086912 | ||
|
|
a41e5b362a | ||
|
|
d4ebbc0b63 | ||
|
|
fccb2650f5 | ||
|
|
e4f74af9c0 | ||
|
|
982cde5623 | ||
|
|
66949356ea | ||
|
|
6952e10390 | ||
|
|
ed99da2d1e | ||
|
|
ed852b3246 | ||
|
|
eec0a49cd6 | ||
|
|
382c08dc0c | ||
|
|
231d1695ff | ||
|
|
97febe9aa1 | ||
|
|
c5a435905b | ||
|
|
86e34593d5 | ||
|
|
3961c684f9 | ||
|
|
b2a415b333 | ||
|
|
1e417fee97 | ||
|
|
47d7c846a3 | ||
|
|
3b236ea04e | ||
|
|
2ec8bcce8b | ||
|
|
966cda2371 | ||
|
|
fcb1de4b93 | ||
|
|
ca61764d2d | ||
|
|
a5946b49f8 | ||
|
|
13d144345e | ||
|
|
b633be9c13 | ||
|
|
f45e09a5a5 | ||
|
|
5b3a0a6e29 | ||
|
|
505bac514f | ||
|
|
39c3ce7ab2 | ||
|
|
419821733c | ||
|
|
8216d0c025 | ||
|
|
98128fabab | ||
|
|
2d36db7822 | ||
|
|
300d132266 | ||
|
|
6330d15ebe | ||
|
|
d7d37f9908 | ||
|
|
fb29db7aad | ||
|
|
76dac29f1c | ||
|
|
e00794bbdf | ||
|
|
a7796cbf5c | ||
|
|
e2f8f29ec8 | ||
|
|
6e8729bb58 | ||
|
|
a0892470e1 | ||
|
|
9fcfa17004 | ||
|
|
58f1ce0331 | ||
|
|
20b4c4fb36 | ||
|
|
965e1664af | ||
|
|
8232c77ef6 | ||
|
|
85bbcb0010 | ||
|
|
338d8459de | ||
|
|
fbf9a81121 | ||
|
|
1f80936805 | ||
|
|
8d424d668d | ||
|
|
b2fcdaa14e | ||
|
|
d4d949b870 | ||
|
|
759ae99b56 | ||
|
|
7104b5b109 | ||
|
|
331a949623 | ||
|
|
cd733d3190 | ||
|
|
6e4bb64b4e | ||
|
|
4a48019885 | ||
|
|
47823132f0 | ||
|
|
bb5c8bbbf1 | ||
|
|
5a0a1ca6a9 | ||
|
|
19cc1e11b9 | ||
|
|
c070c5b0ed | ||
|
|
2e2080d8d1 | ||
|
|
381a7e76be | ||
|
|
6c619ab628 | ||
|
|
ae14dde13d | ||
|
|
e33cf08fca | ||
|
|
f2e9f50d94 | ||
|
|
75259ec230 | ||
|
|
f581f17308 | ||
|
|
8c49e6ba18 | ||
|
|
075c88e5e8 | ||
|
|
47dd3118b1 | ||
|
|
2e85b01242 | ||
|
|
119379028d | ||
|
|
b8bb146422 | ||
|
|
71a2f1955e | ||
|
|
6b154b05a6 | ||
|
|
f8c744e301 | ||
|
|
a7770bda5b | ||
|
|
fef9bcb1e1 | ||
|
|
88e9e39c73 | ||
|
|
16b357e11e | ||
|
|
7c48c13dce | ||
|
|
68eccd3c05 | ||
|
|
33d1022a73 | ||
|
|
08e6833c12 | ||
|
|
9c873127a5 |
@@ -1,4 +1,4 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
npm-debug.log
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
@@ -12,6 +12,21 @@ LICENSE
|
||||
.env.template
|
||||
.github
|
||||
.idea
|
||||
.prettierignore
|
||||
LICENSE.md
|
||||
docs
|
||||
update.sh
|
||||
update.sh
|
||||
.pytest_cache
|
||||
cookbook/tests
|
||||
mediafiles
|
||||
staticfiles
|
||||
db.sqlite3
|
||||
pytest.ini
|
||||
vue/**/*.vue
|
||||
vue/**/*.ts
|
||||
**/.openapi-generator
|
||||
mkdocs.yml
|
||||
vue/babel.config*
|
||||
vue/package.json
|
||||
vue/tsconfig.json
|
||||
vue/src/utils/openapi
|
||||
|
||||
@@ -146,7 +146,12 @@ REVERSE_PROXY_AUTH=0
|
||||
#AUTH_LDAP_BIND_DN=
|
||||
#AUTH_LDAP_BIND_PASSWORD=
|
||||
#AUTH_LDAP_USER_SEARCH_BASE_DN=
|
||||
#AUTH_LDAP_TLS_CACERTFILE=
|
||||
|
||||
# Enables exporting PDF (see export docs)
|
||||
# Disabled by default, uncomment to enable
|
||||
# ENABLE_PDF_EXPORT=1
|
||||
|
||||
# Recipe exports are cached for a certain time by default, adjust time if needed
|
||||
# EXPORT_FILE_CACHE_DURATION=600
|
||||
|
||||
|
||||
@@ -205,9 +205,9 @@ class CustomIsShared(permissions.BasePermission):
|
||||
return request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
# temporary hack to make old shopping list work with new shopping list
|
||||
if obj.__class__.__name__ == 'ShoppingList':
|
||||
return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share())
|
||||
# # temporary hack to make old shopping list work with new shopping list
|
||||
# if obj.__class__.__name__ in ['ShoppingList', 'ShoppingListEntry']:
|
||||
# return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share())
|
||||
return is_object_shared(request.user, obj)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.HelperFunctions import Round, str2bool
|
||||
from cookbook.models import (Ingredient, ShoppingListEntry, ShoppingListRecipe,
|
||||
from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe,
|
||||
SupermarketCategoryRelation)
|
||||
from recipes import settings
|
||||
|
||||
@@ -38,118 +38,272 @@ def shopping_helper(qs, request):
|
||||
return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
|
||||
|
||||
|
||||
# TODO refactor as class
|
||||
def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
|
||||
"""
|
||||
Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
|
||||
:param list_recipe: Modify an existing ShoppingListRecipe
|
||||
:param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
|
||||
:param mealplan: alternatively use a mealplan recipe as source of ingredients
|
||||
:param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
|
||||
:param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
|
||||
:param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
|
||||
"""
|
||||
r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
|
||||
if not r:
|
||||
raise ValueError(_("You must supply a recipe or mealplan"))
|
||||
class RecipeShoppingEditor():
|
||||
def __init__(self, user, space, **kwargs):
|
||||
self.created_by = user
|
||||
self.space = space
|
||||
self._kwargs = {**kwargs}
|
||||
|
||||
created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None)
|
||||
if not created_by:
|
||||
raise ValueError(_("You must supply a created_by"))
|
||||
self.mealplan = self._kwargs.get('mealplan', None)
|
||||
if type(self.mealplan) in [int, float]:
|
||||
self.mealplan = MealPlan.objects.filter(id=self.mealplan, space=self.space)
|
||||
self.id = self._kwargs.get('id', None)
|
||||
|
||||
try:
|
||||
servings = float(servings)
|
||||
except (ValueError, TypeError):
|
||||
servings = getattr(mealplan, 'servings', 1.0)
|
||||
self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
|
||||
|
||||
servings_factor = servings / r.servings
|
||||
if self._shopping_list_recipe:
|
||||
# created_by needs to be sticky to original creator as it is 'their' shopping list
|
||||
# changing shopping list created_by can shift some items to new owner which may not share in the other direction
|
||||
self.created_by = getattr(self._shopping_list_recipe.entries.first(), 'created_by', self.created_by)
|
||||
|
||||
shared_users = list(created_by.get_shopping_share())
|
||||
shared_users.append(created_by)
|
||||
if list_recipe:
|
||||
created = False
|
||||
else:
|
||||
list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
|
||||
created = True
|
||||
self.recipe = getattr(self._shopping_list_recipe, 'recipe', None) or self._kwargs.get('recipe', None) or getattr(self.mealplan, 'recipe', None)
|
||||
if type(self.recipe) in [int, float]:
|
||||
self.recipe = Recipe.objects.filter(id=self.recipe, space=self.space)
|
||||
|
||||
related_step_ing = []
|
||||
if servings == 0 and not created:
|
||||
list_recipe.delete()
|
||||
return []
|
||||
elif ingredients:
|
||||
ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
|
||||
else:
|
||||
ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
|
||||
try:
|
||||
self.servings = float(self._kwargs.get('servings', None))
|
||||
except (ValueError, TypeError):
|
||||
self.servings = getattr(self._shopping_list_recipe, 'servings', None) or getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', None)
|
||||
|
||||
if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
|
||||
ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users])
|
||||
@property
|
||||
def _servings_factor(self):
|
||||
return self.servings / self.recipe.servings
|
||||
|
||||
if related := created_by.userpreference.mealplan_autoinclude_related:
|
||||
# TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
|
||||
related_recipes = r.get_related_recipes()
|
||||
@property
|
||||
def _shared_users(self):
|
||||
return [*list(self.created_by.get_shopping_share()), self.created_by]
|
||||
|
||||
for x in related_recipes:
|
||||
# related recipe is a Step serving size is driven by recipe serving size
|
||||
# TODO once/if Steps can have a serving size this needs to be refactored
|
||||
if exclude_onhand:
|
||||
# if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
|
||||
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).values_list('id', flat=True)
|
||||
else:
|
||||
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
|
||||
@staticmethod
|
||||
def get_shopping_list_recipe(id, user, space):
|
||||
return ShoppingListRecipe.objects.filter(id=id).filter(Q(shoppinglist__space=space) | Q(entries__space=space)).filter(
|
||||
Q(shoppinglist__created_by=user)
|
||||
| Q(shoppinglist__shared=user)
|
||||
| Q(entries__created_by=user)
|
||||
| Q(entries__created_by__in=list(user.get_shopping_share()))
|
||||
).prefetch_related('entries').first()
|
||||
|
||||
x_ing = []
|
||||
if ingredients.filter(food__recipe=x).exists():
|
||||
for ing in ingredients.filter(food__recipe=x):
|
||||
if exclude_onhand:
|
||||
x_ing = Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
|
||||
else:
|
||||
x_ing = Ingredient.objects.filter(step__recipe=x, space=space)
|
||||
for i in [x for x in x_ing]:
|
||||
ShoppingListEntry.objects.create(
|
||||
list_recipe=list_recipe,
|
||||
food=i.food,
|
||||
unit=i.unit,
|
||||
ingredient=i,
|
||||
amount=i.amount * Decimal(servings_factor),
|
||||
created_by=created_by,
|
||||
space=space,
|
||||
)
|
||||
# dont' add food to the shopping list that are actually recipes that will be added as ingredients
|
||||
ingredients = ingredients.exclude(food__recipe=x)
|
||||
def get_recipe_ingredients(self, id, exclude_onhand=False):
|
||||
if exclude_onhand:
|
||||
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude(food__onhand_users__id__in=[x.id for x in self._shared_users])
|
||||
else:
|
||||
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space)
|
||||
|
||||
add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing
|
||||
if not append:
|
||||
existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
|
||||
# delete shopping list entries not included in ingredients
|
||||
existing_list.exclude(ingredient__in=ingredients).delete()
|
||||
# add shopping list entries that did not previously exist
|
||||
add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
|
||||
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
|
||||
@property
|
||||
def _include_related(self):
|
||||
return self.created_by.userpreference.mealplan_autoinclude_related
|
||||
|
||||
# if servings have changed, update the ShoppingListRecipe and existing Entries
|
||||
if servings <= 0:
|
||||
servings = 1
|
||||
@property
|
||||
def _exclude_onhand(self):
|
||||
return self.created_by.userpreference.mealplan_autoexclude_onhand
|
||||
|
||||
if not created and list_recipe.servings != servings:
|
||||
update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
|
||||
list_recipe.servings = servings
|
||||
list_recipe.save()
|
||||
for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
|
||||
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
|
||||
def create(self, **kwargs):
|
||||
ingredients = kwargs.get('ingredients', None)
|
||||
exclude_onhand = not ingredients and self._exclude_onhand
|
||||
if servings := kwargs.get('servings', None):
|
||||
self.servings = float(servings)
|
||||
|
||||
if mealplan := kwargs.get('mealplan', None):
|
||||
self.mealplan = mealplan
|
||||
self.recipe = mealplan.recipe
|
||||
elif recipe := kwargs.get('recipe', None):
|
||||
self.recipe = recipe
|
||||
|
||||
if not self.servings:
|
||||
self.servings = getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', 1.0)
|
||||
|
||||
self._shopping_list_recipe = ShoppingListRecipe.objects.create(recipe=self.recipe, mealplan=self.mealplan, servings=self.servings)
|
||||
|
||||
if ingredients:
|
||||
self._add_ingredients(ingredients=ingredients)
|
||||
else:
|
||||
if self._include_related:
|
||||
related = self.recipe.get_related_recipes()
|
||||
self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related))
|
||||
for r in related:
|
||||
self._add_ingredients(self.get_recipe_ingredients(r.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related))
|
||||
else:
|
||||
self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand))
|
||||
|
||||
return True
|
||||
|
||||
def add(self, **kwargs):
|
||||
return
|
||||
|
||||
def edit(self, servings=None, ingredients=None, **kwargs):
|
||||
if servings:
|
||||
self.servings = servings
|
||||
|
||||
self._delete_ingredients(ingredients=ingredients)
|
||||
if self.servings != self._shopping_list_recipe.servings:
|
||||
self.edit_servings()
|
||||
self._add_ingredients(ingredients=ingredients)
|
||||
return True
|
||||
|
||||
def edit_servings(self, servings=None, **kwargs):
|
||||
if servings:
|
||||
self.servings = servings
|
||||
if id := kwargs.get('id', None):
|
||||
self._shopping_list_recipe = self.get_shopping_list_recipe(id, self.created_by, self.space)
|
||||
if not self.servings:
|
||||
raise ValueError(_("You must supply a servings size"))
|
||||
|
||||
if self._shopping_list_recipe.servings == self.servings:
|
||||
return True
|
||||
|
||||
for sle in ShoppingListEntry.objects.filter(list_recipe=self._shopping_list_recipe):
|
||||
sle.amount = sle.ingredient.amount * Decimal(self._servings_factor)
|
||||
sle.save()
|
||||
self._shopping_list_recipe.servings = self.servings
|
||||
self._shopping_list_recipe.save()
|
||||
return True
|
||||
|
||||
# add any missing Entries
|
||||
for i in [x for x in add_ingredients if x.food]:
|
||||
def delete(self, **kwargs):
|
||||
try:
|
||||
self._shopping_list_recipe.delete()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
ShoppingListEntry.objects.create(
|
||||
list_recipe=list_recipe,
|
||||
food=i.food,
|
||||
unit=i.unit,
|
||||
ingredient=i,
|
||||
amount=i.amount * Decimal(servings_factor),
|
||||
created_by=created_by,
|
||||
space=space,
|
||||
)
|
||||
def _add_ingredients(self, ingredients=None):
|
||||
if not ingredients:
|
||||
return
|
||||
elif type(ingredients) == list:
|
||||
ingredients = Ingredient.objects.filter(id__in=ingredients)
|
||||
existing = self._shopping_list_recipe.entries.filter(ingredient__in=ingredients).values_list('ingredient__pk', flat=True)
|
||||
add_ingredients = ingredients.exclude(id__in=existing)
|
||||
|
||||
# return all shopping list items
|
||||
return list_recipe
|
||||
for i in [x for x in add_ingredients if x.food]:
|
||||
ShoppingListEntry.objects.create(
|
||||
list_recipe=self._shopping_list_recipe,
|
||||
food=i.food,
|
||||
unit=i.unit,
|
||||
ingredient=i,
|
||||
amount=i.amount * Decimal(self._servings_factor),
|
||||
created_by=self.created_by,
|
||||
space=self.space,
|
||||
)
|
||||
|
||||
# deletes shopping list entries not in ingredients list
|
||||
def _delete_ingredients(self, ingredients=None):
|
||||
if not ingredients:
|
||||
return
|
||||
to_delete = self._shopping_list_recipe.entries.exclude(ingredient__in=ingredients)
|
||||
ShoppingListEntry.objects.filter(id__in=to_delete).delete()
|
||||
self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
|
||||
|
||||
|
||||
# # TODO refactor as class
|
||||
# def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
|
||||
# """
|
||||
# Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
|
||||
# :param list_recipe: Modify an existing ShoppingListRecipe
|
||||
# :param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
|
||||
# :param mealplan: alternatively use a mealplan recipe as source of ingredients
|
||||
# :param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
|
||||
# :param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
|
||||
# :param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
|
||||
# """
|
||||
# r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
|
||||
# if not r:
|
||||
# raise ValueError(_("You must supply a recipe or mealplan"))
|
||||
|
||||
# created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None)
|
||||
# if not created_by:
|
||||
# raise ValueError(_("You must supply a created_by"))
|
||||
|
||||
# try:
|
||||
# servings = float(servings)
|
||||
# except (ValueError, TypeError):
|
||||
# servings = getattr(mealplan, 'servings', 1.0)
|
||||
|
||||
# servings_factor = servings / r.servings
|
||||
|
||||
# shared_users = list(created_by.get_shopping_share())
|
||||
# shared_users.append(created_by)
|
||||
# if list_recipe:
|
||||
# created = False
|
||||
# else:
|
||||
# list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
|
||||
# created = True
|
||||
|
||||
# related_step_ing = []
|
||||
# if servings == 0 and not created:
|
||||
# list_recipe.delete()
|
||||
# return []
|
||||
# elif ingredients:
|
||||
# ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
|
||||
# else:
|
||||
# ingredients = Ingredient.objects.filter(step__recipe=r, food__ignore_shopping=False, space=space)
|
||||
|
||||
# if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
|
||||
# ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users])
|
||||
|
||||
# if related := created_by.userpreference.mealplan_autoinclude_related:
|
||||
# # TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
|
||||
# related_recipes = r.get_related_recipes()
|
||||
|
||||
# for x in related_recipes:
|
||||
# # related recipe is a Step serving size is driven by recipe serving size
|
||||
# # TODO once/if Steps can have a serving size this needs to be refactored
|
||||
# if exclude_onhand:
|
||||
# # if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
|
||||
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).values_list('id', flat=True)
|
||||
# else:
|
||||
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
|
||||
|
||||
# x_ing = []
|
||||
# if ingredients.filter(food__recipe=x).exists():
|
||||
# for ing in ingredients.filter(food__recipe=x):
|
||||
# if exclude_onhand:
|
||||
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
|
||||
# else:
|
||||
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__ignore_shopping=True)
|
||||
# for i in [x for x in x_ing]:
|
||||
# ShoppingListEntry.objects.create(
|
||||
# list_recipe=list_recipe,
|
||||
# food=i.food,
|
||||
# unit=i.unit,
|
||||
# ingredient=i,
|
||||
# amount=i.amount * Decimal(servings_factor),
|
||||
# created_by=created_by,
|
||||
# space=space,
|
||||
# )
|
||||
# # dont' add food to the shopping list that are actually recipes that will be added as ingredients
|
||||
# ingredients = ingredients.exclude(food__recipe=x)
|
||||
|
||||
# add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing
|
||||
# if not append:
|
||||
# existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
|
||||
# # delete shopping list entries not included in ingredients
|
||||
# existing_list.exclude(ingredient__in=ingredients).delete()
|
||||
# # add shopping list entries that did not previously exist
|
||||
# add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
|
||||
# add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
|
||||
|
||||
# # if servings have changed, update the ShoppingListRecipe and existing Entries
|
||||
# if servings <= 0:
|
||||
# servings = 1
|
||||
|
||||
# if not created and list_recipe.servings != servings:
|
||||
# update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
|
||||
# list_recipe.servings = servings
|
||||
# list_recipe.save()
|
||||
# for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
|
||||
# sle.amount = sle.ingredient.amount * Decimal(servings_factor)
|
||||
# sle.save()
|
||||
|
||||
# # add any missing Entries
|
||||
# for i in [x for x in add_ingredients if x.food]:
|
||||
|
||||
# ShoppingListEntry.objects.create(
|
||||
# list_recipe=list_recipe,
|
||||
# food=i.food,
|
||||
# unit=i.unit,
|
||||
# ingredient=i,
|
||||
# amount=i.amount * Decimal(servings_factor),
|
||||
# created_by=created_by,
|
||||
# space=space,
|
||||
# )
|
||||
|
||||
# # return all shopping list items
|
||||
# return list_recipe
|
||||
|
||||
@@ -32,11 +32,12 @@ class Default(Integration):
|
||||
return None
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
|
||||
export = RecipeExportSerializer(recipe).data
|
||||
|
||||
return 'recipe.json', JSONRenderer().render(export).decode("utf-8")
|
||||
|
||||
def get_files_from_recipes(self, recipes, cookie):
|
||||
def get_files_from_recipes(self, recipes, el, cookie):
|
||||
export_zip_stream = BytesIO()
|
||||
export_zip_obj = ZipFile(export_zip_stream, 'w')
|
||||
|
||||
@@ -50,13 +51,20 @@ class Default(Integration):
|
||||
recipe_stream.write(data)
|
||||
recipe_zip_obj.writestr(filename, recipe_stream.getvalue())
|
||||
recipe_stream.close()
|
||||
|
||||
try:
|
||||
recipe_zip_obj.writestr(f'image{get_filetype(r.image.file.name)}', r.image.file.read())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
recipe_zip_obj.close()
|
||||
|
||||
export_zip_obj.writestr(str(r.pk) + '.zip', recipe_zip_stream.getvalue())
|
||||
|
||||
el.exported_recipes += 1
|
||||
el.msg += self.get_recipe_processed_msg(r)
|
||||
el.save()
|
||||
|
||||
export_zip_obj.close()
|
||||
|
||||
return [[ 'export.zip', export_zip_stream.getvalue() ]]
|
||||
return [[ self.get_export_file_name(), export_zip_stream.getvalue() ]]
|
||||
@@ -1,9 +1,12 @@
|
||||
import time
|
||||
import datetime
|
||||
import json
|
||||
import traceback
|
||||
import uuid
|
||||
from io import BytesIO, StringIO
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
from django.core.cache import cache
|
||||
import datetime
|
||||
|
||||
from bs4 import Tag
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
@@ -18,6 +21,7 @@ from cookbook.forms import ImportExportBase
|
||||
from cookbook.helper.image_processing import get_filetype, handle_image
|
||||
from cookbook.models import Keyword, Recipe
|
||||
from recipes.settings import DEBUG
|
||||
from recipes.settings import EXPORT_FILE_CACHE_DURATION
|
||||
|
||||
|
||||
class Integration:
|
||||
@@ -61,35 +65,44 @@ class Integration:
|
||||
space=request.space
|
||||
)
|
||||
|
||||
def do_export(self, recipes):
|
||||
"""
|
||||
Perform the export based on a list of recipes
|
||||
:param recipes: list of recipe objects
|
||||
:return: HttpResponse with the file of the requested export format that is directly downloaded (When that format involve multiple files they are zipped together)
|
||||
"""
|
||||
|
||||
files = self.get_files_from_recipes(recipes, self.request.COOKIES)
|
||||
|
||||
if len(files) == 1:
|
||||
filename, file = files[0]
|
||||
export_filename = filename
|
||||
export_file = file
|
||||
def do_export(self, recipes, el):
|
||||
|
||||
else:
|
||||
export_filename = "export.zip"
|
||||
export_stream = BytesIO()
|
||||
export_obj = ZipFile(export_stream, 'w')
|
||||
with scope(space=self.request.space):
|
||||
el.total_recipes = len(recipes)
|
||||
el.cache_duration = EXPORT_FILE_CACHE_DURATION
|
||||
el.save()
|
||||
|
||||
for filename, file in files:
|
||||
export_obj.writestr(filename, file)
|
||||
files = self.get_files_from_recipes(recipes, el, self.request.COOKIES)
|
||||
|
||||
export_obj.close()
|
||||
export_file = export_stream.getvalue()
|
||||
if len(files) == 1:
|
||||
filename, file = files[0]
|
||||
export_filename = filename
|
||||
export_file = file
|
||||
|
||||
else:
|
||||
#zip the files if there is more then one file
|
||||
export_filename = self.get_export_file_name()
|
||||
export_stream = BytesIO()
|
||||
export_obj = ZipFile(export_stream, 'w')
|
||||
|
||||
for filename, file in files:
|
||||
export_obj.writestr(filename, file)
|
||||
|
||||
export_obj.close()
|
||||
export_file = export_stream.getvalue()
|
||||
|
||||
|
||||
cache.set('export_file_'+str(el.pk), {'filename': export_filename, 'file': export_file}, EXPORT_FILE_CACHE_DURATION)
|
||||
el.running = False
|
||||
el.save()
|
||||
|
||||
response = HttpResponse(export_file, content_type='application/force-download')
|
||||
response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"'
|
||||
return response
|
||||
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
"""
|
||||
Since zipfile.namelist() returns all files in all subdirectories this function allows filtering of files
|
||||
@@ -126,7 +139,7 @@ class Integration:
|
||||
for d in data_list:
|
||||
recipe = self.get_recipe_from_file(d)
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
il.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
@@ -151,7 +164,7 @@ class Integration:
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
il.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
@@ -166,7 +179,7 @@ class Integration:
|
||||
try:
|
||||
recipe = self.get_recipe_from_file(d)
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
il.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
@@ -183,7 +196,7 @@ class Integration:
|
||||
try:
|
||||
recipe = self.get_recipe_from_file(d)
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
il.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
@@ -193,7 +206,7 @@ class Integration:
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(f['file'])
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
il.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
except BadZipFile:
|
||||
il.msg += 'ERROR ' + _(
|
||||
@@ -260,7 +273,7 @@ class Integration:
|
||||
"""
|
||||
raise NotImplementedError('Method not implemented in integration')
|
||||
|
||||
def get_files_from_recipes(self, recipes, cookie):
|
||||
def get_files_from_recipes(self, recipes, el, cookie):
|
||||
"""
|
||||
Takes a list of recipe object and converts it to a array containing each file.
|
||||
Each file is represented as an array [filename, data] where data is a string of the content of the file.
|
||||
@@ -279,3 +292,10 @@ class Integration:
|
||||
log.msg += exception.msg
|
||||
if DEBUG:
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def get_export_file_name(self, format='zip'):
|
||||
return "export_{}.{}".format(datetime.datetime.now().strftime("%Y-%m-%d"), format)
|
||||
|
||||
def get_recipe_processed_msg(self, recipe):
|
||||
return f'{recipe.pk} - {recipe.name} \n'
|
||||
|
||||
@@ -11,22 +11,25 @@ from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.serializer import RecipeExportSerializer
|
||||
|
||||
import django.core.management.commands.runserver as runserver
|
||||
from cookbook.models import ExportLog
|
||||
from asgiref.sync import sync_to_async
|
||||
|
||||
import django.core.management.commands.runserver as runserver
|
||||
import logging
|
||||
|
||||
class PDFexport(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
async def get_files_from_recipes_async(self, recipes, cookie):
|
||||
async def get_files_from_recipes_async(self, recipes, el, cookie):
|
||||
cmd = runserver.Command()
|
||||
|
||||
browser = await launch(
|
||||
handleSIGINT=False,
|
||||
handleSIGTERM=False,
|
||||
handleSIGHUP=False,
|
||||
ignoreHTTPSErrors=True
|
||||
ignoreHTTPSErrors=True,
|
||||
)
|
||||
|
||||
cookies = {'domain': cmd.default_addr, 'name': 'sessionid', 'value': cookie['sessionid'], }
|
||||
@@ -39,17 +42,28 @@ class PDFexport(Integration):
|
||||
}
|
||||
}
|
||||
|
||||
page = await browser.newPage()
|
||||
await page.emulateMedia('print')
|
||||
await page.setCookie(cookies)
|
||||
|
||||
files = []
|
||||
for recipe in recipes:
|
||||
await page.goto('http://' + cmd.default_addr + ':' + cmd.default_port + '/view/recipe/' + str(recipe.id), {'waitUntil': 'networkidle0', })
|
||||
|
||||
page = await browser.newPage()
|
||||
await page.emulateMedia('print')
|
||||
await page.setCookie(cookies)
|
||||
|
||||
await page.goto('http://'+cmd.default_addr+':'+cmd.default_port+'/view/recipe/'+str(recipe.id), {'waitUntil': 'domcontentloaded'})
|
||||
await page.waitForSelector('#printReady');
|
||||
|
||||
files.append([recipe.name + '.pdf', await page.pdf(options)])
|
||||
await page.close();
|
||||
|
||||
el.exported_recipes += 1
|
||||
el.msg += self.get_recipe_processed_msg(recipe)
|
||||
await sync_to_async(el.save, thread_sensitive=True)()
|
||||
|
||||
|
||||
await browser.close()
|
||||
return files
|
||||
|
||||
def get_files_from_recipes(self, recipes, cookie):
|
||||
return asyncio.run(self.get_files_from_recipes_async(recipes, cookie))
|
||||
|
||||
def get_files_from_recipes(self, recipes, el, cookie):
|
||||
return asyncio.run(self.get_files_from_recipes_async(recipes, el, cookie))
|
||||
|
||||
@@ -88,12 +88,16 @@ class RecipeSage(Integration):
|
||||
|
||||
return data
|
||||
|
||||
def get_files_from_recipes(self, recipes, cookie):
|
||||
def get_files_from_recipes(self, recipes, el, cookie):
|
||||
json_list = []
|
||||
for r in recipes:
|
||||
json_list.append(self.get_file_from_recipe(r))
|
||||
|
||||
return [['export.json', json.dumps(json_list)]]
|
||||
el.exported_recipes += 1
|
||||
el.msg += self.get_recipe_processed_msg(r)
|
||||
el.save()
|
||||
|
||||
return [[self.get_export_file_name('json'), json.dumps(json_list)]]
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
return json.loads(file.read().decode("utf-8"))
|
||||
|
||||
@@ -87,10 +87,14 @@ class Saffron(Integration):
|
||||
|
||||
return recipe.name+'.txt', data
|
||||
|
||||
def get_files_from_recipes(self, recipes, cookie):
|
||||
def get_files_from_recipes(self, recipes, el, cookie):
|
||||
files = []
|
||||
for r in recipes:
|
||||
filename, data = self.get_file_from_recipe(r)
|
||||
files.append([ filename, data ])
|
||||
|
||||
el.exported_recipes += 1
|
||||
el.msg += self.get_recipe_processed_msg(r)
|
||||
el.save()
|
||||
|
||||
return files
|
||||
@@ -15,8 +15,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
|
||||
"PO-Revision-Date: 2022-01-20 22:47+0000\n"
|
||||
"Last-Translator: Sebastian Weber <tandoor@web3r.de>\n"
|
||||
"PO-Revision-Date: 2022-02-02 15:31+0000\n"
|
||||
"Last-Translator: Sven <tr@sutikal.de>\n"
|
||||
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/de/>\n"
|
||||
"Language: de\n"
|
||||
@@ -24,7 +24,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 4.8\n"
|
||||
"X-Generator: Weblate 4.10.1\n"
|
||||
|
||||
#: .\cookbook\filters.py:23 .\cookbook\templates\forms\ingredients.html:34
|
||||
#: .\cookbook\templates\space.html:50 .\cookbook\templates\stats.html:28
|
||||
@@ -104,22 +104,16 @@ msgid ""
|
||||
"Enables support for fractions in ingredient amounts (e.g. convert decimals "
|
||||
"to fractions automatically)"
|
||||
msgstr ""
|
||||
"Unterstützung für Brüche in Zutaten aktivieren. Dadurch werden Dezimalzahlen "
|
||||
"mit Brüchen ersetzt, z.B. 0.5 mit ½."
|
||||
"Unterstützung für Brüche in Zutaten aktivieren (dadurch werden Dezimalzahlen "
|
||||
"automatisch mit Brüchen ersetzt)"
|
||||
|
||||
#: .\cookbook\forms.py:78
|
||||
msgid "Display nutritional energy amounts in joules instead of calories"
|
||||
msgstr "Nährwerte in Joule statt Kalorien anzeigen"
|
||||
|
||||
#: .\cookbook\forms.py:79
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "Users with whom newly created meal plan/shopping list entries should be "
|
||||
#| "shared by default."
|
||||
msgid "Users with whom newly created meal plans should be shared by default."
|
||||
msgstr ""
|
||||
"Nutzer, mit denen neue Pläne und Einkaufslisten standardmäßig geteilt werden "
|
||||
"sollen."
|
||||
msgstr "Nutzer, mit denen neue Essenspläne standardmäßig geteilt werden sollen."
|
||||
|
||||
#: .\cookbook\forms.py:80
|
||||
msgid "Users with whom to share shopping lists."
|
||||
@@ -157,11 +151,11 @@ msgstr "Navigationsleiste wird oben angeheftet."
|
||||
|
||||
#: .\cookbook\forms.py:90 .\cookbook\forms.py:496
|
||||
msgid "Automatically add meal plan ingredients to shopping list."
|
||||
msgstr ""
|
||||
msgstr "Fügt die Zutaten des Speiseplans automatisch zur Einkaufsliste hinzu."
|
||||
|
||||
#: .\cookbook\forms.py:91
|
||||
msgid "Exclude ingredients that are on hand."
|
||||
msgstr ""
|
||||
msgstr "Zutaten, die vorrätig sind, ausschließen."
|
||||
|
||||
#: .\cookbook\forms.py:108
|
||||
msgid ""
|
||||
@@ -319,8 +313,7 @@ msgid ""
|
||||
"degrade search quality depending on language"
|
||||
msgstr ""
|
||||
"Felder bei welchen Akzente ignoriert werden. Das aktivieren dieser Option "
|
||||
"kann die Suchqualität abhängig von der Sprache verbessern oder "
|
||||
"verschlechtern."
|
||||
"kann die Suchqualität je nach Sprache verbessern oder verschlechtern"
|
||||
|
||||
#: .\cookbook\forms.py:450
|
||||
msgid ""
|
||||
@@ -388,26 +381,36 @@ msgid ""
|
||||
"Users will see all items you add to your shopping list. They must add you "
|
||||
"to see items on their list."
|
||||
msgstr ""
|
||||
"Die Benutzer sehen alle Artikel, die Sie auf Ihre Einkaufsliste setzen. Die "
|
||||
"Benutzer müssen Sie hinzufügen, damit Sie Artikel auf der Liste der Benutzer "
|
||||
"sehen können."
|
||||
|
||||
#: .\cookbook\forms.py:497
|
||||
msgid ""
|
||||
"When adding a meal plan to the shopping list (manually or automatically), "
|
||||
"include all related recipes."
|
||||
msgstr ""
|
||||
"Wenn Sie einen Essensplan zur Einkaufsliste hinzufügen (manuell oder "
|
||||
"automatisch), fügen Sie alle zugehörigen Rezepte hinzu."
|
||||
|
||||
#: .\cookbook\forms.py:498
|
||||
msgid ""
|
||||
"When adding a meal plan to the shopping list (manually or automatically), "
|
||||
"exclude ingredients that are on hand."
|
||||
msgstr ""
|
||||
"Wenn Sie einen Essensplan zur Einkaufsliste hinzufügen (manuell oder "
|
||||
"automatisch), schließen Sie Zutaten aus, die Sie gerade zur Hand haben."
|
||||
|
||||
#: .\cookbook\forms.py:499
|
||||
msgid "Default number of hours to delay a shopping list entry."
|
||||
msgstr ""
|
||||
"Voreingestellte Anzahl von Stunden für die Verzögerung eines "
|
||||
"Einkaufslisteneintrags."
|
||||
|
||||
#: .\cookbook\forms.py:500
|
||||
msgid "Filter shopping list to only include supermarket categories."
|
||||
msgstr ""
|
||||
"Nur für den Supermarkt konfigurierte Kategorien in Einkaufsliste anzeigen."
|
||||
|
||||
#: .\cookbook\forms.py:501
|
||||
msgid "Days of recent shopping list entries to display."
|
||||
@@ -416,54 +419,54 @@ msgstr ""
|
||||
#: .\cookbook\forms.py:502
|
||||
msgid "Mark food 'On Hand' when checked off shopping list."
|
||||
msgstr ""
|
||||
"Lebensmittel als vorrätig markieren, wenn es in der Einkaufliste abgehakt "
|
||||
"wurde."
|
||||
|
||||
#: .\cookbook\forms.py:503
|
||||
msgid "Delimiter to use for CSV exports."
|
||||
msgstr ""
|
||||
msgstr "Separator für CSV-Export."
|
||||
|
||||
#: .\cookbook\forms.py:504
|
||||
msgid "Prefix to add when copying list to the clipboard."
|
||||
msgstr ""
|
||||
msgstr "Zusatz wird der in die Zwischenablage kopierten Liste vorangestellt."
|
||||
|
||||
#: .\cookbook\forms.py:508
|
||||
#, fuzzy
|
||||
#| msgid "New Shopping List"
|
||||
msgid "Share Shopping List"
|
||||
msgstr "Neue Einkaufsliste"
|
||||
msgstr "Einkaufsliste teilen"
|
||||
|
||||
#: .\cookbook\forms.py:509
|
||||
msgid "Autosync"
|
||||
msgstr ""
|
||||
msgstr "Automatischer Abgleich"
|
||||
|
||||
#: .\cookbook\forms.py:510
|
||||
msgid "Auto Add Meal Plan"
|
||||
msgstr ""
|
||||
msgstr "automatisch dem Menüplan hinzufügen"
|
||||
|
||||
#: .\cookbook\forms.py:511
|
||||
msgid "Exclude On Hand"
|
||||
msgstr ""
|
||||
msgstr "Ausgenommen Vorrätiges"
|
||||
|
||||
#: .\cookbook\forms.py:512
|
||||
msgid "Include Related"
|
||||
msgstr ""
|
||||
msgstr "dazugehörend"
|
||||
|
||||
#: .\cookbook\forms.py:513
|
||||
msgid "Default Delay Hours"
|
||||
msgstr ""
|
||||
msgstr "Standardmäßige Verzögerung in Stunden"
|
||||
|
||||
#: .\cookbook\forms.py:514
|
||||
#, fuzzy
|
||||
#| msgid "Select Supermarket"
|
||||
msgid "Filter to Supermarket"
|
||||
msgstr "Supermarkt auswählen"
|
||||
msgstr "Supermarkt filtern"
|
||||
|
||||
#: .\cookbook\forms.py:515
|
||||
msgid "Recent Days"
|
||||
msgstr ""
|
||||
msgstr "Vergangene Tage"
|
||||
|
||||
#: .\cookbook\forms.py:516
|
||||
msgid "CSV Delimiter"
|
||||
msgstr ""
|
||||
msgstr "CSV Trennzeichen"
|
||||
|
||||
#: .\cookbook\forms.py:517 .\cookbook\templates\shopping_list.html:322
|
||||
msgid "List Prefix"
|
||||
@@ -471,7 +474,7 @@ msgstr "Listenpräfix"
|
||||
|
||||
#: .\cookbook\forms.py:518
|
||||
msgid "Auto On Hand"
|
||||
msgstr ""
|
||||
msgstr "Automatisch als vorrätig markieren"
|
||||
|
||||
#: .\cookbook\forms.py:528
|
||||
msgid "Reset Food Inheritance"
|
||||
@@ -482,16 +485,12 @@ msgid "Reset all food to inherit the fields configured."
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:541
|
||||
#, fuzzy
|
||||
#| msgid "Food that should be replaced."
|
||||
msgid "Fields on food that should be inherited by default."
|
||||
msgstr "Zutat, die ersetzt werden soll."
|
||||
msgstr "Zutaten, die standardmäßig übernommen werden sollen."
|
||||
|
||||
#: .\cookbook\forms.py:542
|
||||
#, fuzzy
|
||||
#| msgid "Show recently viewed recipes on search page."
|
||||
msgid "Show recipe counts on search filters"
|
||||
msgstr "Zuletzt angeschaute Rezepte bei der Suche anzeigen."
|
||||
msgstr "Rezeptanzahl im Suchfiltern anzeigen"
|
||||
|
||||
#: .\cookbook\helper\AllAuthCustomAdapter.py:36
|
||||
msgid ""
|
||||
@@ -527,17 +526,15 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\helper\recipe_search.py:473
|
||||
msgid "One of queryset or hash_key must be provided"
|
||||
msgstr ""
|
||||
msgstr "Es muss die Abfrage oder der Hash_Key angeben werden"
|
||||
|
||||
#: .\cookbook\helper\shopping_helper.py:54
|
||||
#, fuzzy
|
||||
#| msgid "You must provide at least a recipe or a title."
|
||||
msgid "You must supply a recipe or mealplan"
|
||||
msgstr "Mindestens ein Rezept oder ein Titel müssen angegeben werden."
|
||||
msgstr "Mindestens ein Rezept oder ein Essensplan müssen angegeben werden"
|
||||
|
||||
#: .\cookbook\helper\shopping_helper.py:58
|
||||
msgid "You must supply a created_by"
|
||||
msgstr ""
|
||||
msgstr "Die Angabe der Verfassers ist notwendig"
|
||||
|
||||
#: .\cookbook\helper\template_helper.py:61
|
||||
#: .\cookbook\helper\template_helper.py:63
|
||||
@@ -730,7 +727,7 @@ msgstr "Stichwort Alias"
|
||||
|
||||
#: .\cookbook\serializer.py:175
|
||||
msgid "A user is required"
|
||||
msgstr ""
|
||||
msgstr "Ein Benutzername ist notwendig"
|
||||
|
||||
#: .\cookbook\serializer.py:195
|
||||
msgid "File uploads are not enabled for this Space."
|
||||
@@ -742,7 +739,7 @@ msgstr "Du hast Dein Datei-Uploadlimit erreicht."
|
||||
|
||||
#: .\cookbook\serializer.py:962
|
||||
msgid "Existing shopping list to update"
|
||||
msgstr ""
|
||||
msgstr "Bestehende Einkaufliste, die aktualisiert werden soll"
|
||||
|
||||
#: .\cookbook\serializer.py:964
|
||||
msgid ""
|
||||
@@ -758,10 +755,11 @@ msgstr ""
|
||||
#: .\cookbook\serializer.py:973
|
||||
msgid "Amount of food to add to the shopping list"
|
||||
msgstr ""
|
||||
"Menge des Lebensmittels, welches der Einkaufsliste hinzugefügt werden soll"
|
||||
|
||||
#: .\cookbook\serializer.py:974
|
||||
msgid "ID of unit to use for the shopping list"
|
||||
msgstr ""
|
||||
msgstr "ID der Einheit, die für die Einkaufsliste verwendet werden soll"
|
||||
|
||||
#: .\cookbook\serializer.py:975
|
||||
msgid "When set to true will delete all food from active shopping lists."
|
||||
@@ -1332,11 +1330,11 @@ msgstr "Abbrechen"
|
||||
|
||||
#: .\cookbook\templates\generic\edit_template.html:32
|
||||
msgid "View"
|
||||
msgstr "Anschauen"
|
||||
msgstr "Ansicht"
|
||||
|
||||
#: .\cookbook\templates\generic\edit_template.html:36
|
||||
msgid "Delete original file"
|
||||
msgstr "Original löschen"
|
||||
msgstr "Originaldatei löschen"
|
||||
|
||||
#: .\cookbook\templates\generic\list_template.html:6
|
||||
#: .\cookbook\templates\generic\list_template.html:22
|
||||
@@ -2045,10 +2043,8 @@ msgid "Search-Settings"
|
||||
msgstr "Sucheinstellungen"
|
||||
|
||||
#: .\cookbook\templates\settings.html:56
|
||||
#, fuzzy
|
||||
#| msgid "Search-Settings"
|
||||
msgid "Shopping-Settings"
|
||||
msgstr "Sucheinstellungen"
|
||||
msgstr "Einstellungen Einkaufsliste"
|
||||
|
||||
#: .\cookbook\templates\settings.html:65
|
||||
msgid "Name Settings"
|
||||
@@ -2161,10 +2157,8 @@ msgid "Perfect for large Databases"
|
||||
msgstr "Ideal für große Datenbanken"
|
||||
|
||||
#: .\cookbook\templates\settings.html:207
|
||||
#, fuzzy
|
||||
#| msgid "Shopping List"
|
||||
msgid "Shopping Settings"
|
||||
msgstr "Einkaufsliste"
|
||||
msgstr "Einstellungen Einkaufsliste"
|
||||
|
||||
#: .\cookbook\templates\setup.html:6 .\cookbook\templates\system.html:5
|
||||
msgid "Cookbook Setup"
|
||||
@@ -2774,12 +2768,12 @@ msgstr ""
|
||||
#: .\cookbook\views\api.py:470
|
||||
#, python-brace-format
|
||||
msgid "{obj.name} was removed from the shopping list."
|
||||
msgstr ""
|
||||
msgstr "{obj.name} wurde von der Einkaufsliste entfernt."
|
||||
|
||||
#: .\cookbook\views\api.py:475 .\cookbook\views\api.py:726
|
||||
#, python-brace-format
|
||||
msgid "{obj.name} was added to the shopping list."
|
||||
msgstr ""
|
||||
msgstr "{obj.name} wurde der Einkaufsliste hinzugefügt."
|
||||
|
||||
#: .\cookbook\views\api.py:587
|
||||
msgid "ID of recipe a step is part of. For multiple repeat parameter."
|
||||
@@ -2805,11 +2799,11 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\views\api.py:634
|
||||
msgid "ID of unit a recipe should have."
|
||||
msgstr ""
|
||||
msgstr "ID der Einheit, die ein Rezept haben sollte."
|
||||
|
||||
#: .\cookbook\views\api.py:635
|
||||
msgid "Rating a recipe should have. [0 - 5]"
|
||||
msgstr ""
|
||||
msgstr "Bewertung, die ein Rezept haben sollte. [ 0 - 5]"
|
||||
|
||||
#: .\cookbook\views\api.py:636
|
||||
msgid "ID of book a recipe should be in. For multiple repeat parameter."
|
||||
@@ -2897,7 +2891,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\views\api.py:1082
|
||||
msgid "Connection Refused."
|
||||
msgstr ""
|
||||
msgstr "Verbindung fehlgeschlagen."
|
||||
|
||||
#: .\cookbook\views\api.py:1091
|
||||
msgid "No useable data could be found."
|
||||
@@ -2984,6 +2978,8 @@ msgid ""
|
||||
"The PDF Exporter is not enabled on this instance as it is still in an "
|
||||
"experimental state."
|
||||
msgstr ""
|
||||
"Der PDF-Exporter ist in dieser Instanz nicht aktiviert, da er sich noch in "
|
||||
"einem experimentellen Zustand befindet."
|
||||
|
||||
#: .\cookbook\views\import_export.py:132
|
||||
msgid "Exporting is not implemented for this provider"
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: 2021-11-16 06:06+0000\n"
|
||||
"Last-Translator: Luka <storek00@gmail.com>\n"
|
||||
"PO-Revision-Date: 2022-02-02 15:31+0000\n"
|
||||
"Last-Translator: Mario Dvorsek <mario.dvorsek@gmail.com>\n"
|
||||
"Language-Team: Slovenian <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/sl/>\n"
|
||||
"Language: sl\n"
|
||||
@@ -18,7 +18,7 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n"
|
||||
"%100==4 ? 2 : 3;\n"
|
||||
"X-Generator: Weblate 4.8\n"
|
||||
"X-Generator: Weblate 4.10.1\n"
|
||||
|
||||
#: .\cookbook\filters.py:23 .\cookbook\templates\base.html:125
|
||||
#: .\cookbook\templates\forms\ingredients.html:34
|
||||
@@ -33,47 +33,47 @@ msgstr "Privzeta enota"
|
||||
|
||||
#: .\cookbook\forms.py:55
|
||||
msgid "Use fractions"
|
||||
msgstr ""
|
||||
msgstr "Uporabi ulomke/frakcije"
|
||||
|
||||
#: .\cookbook\forms.py:56
|
||||
msgid "Use KJ"
|
||||
msgstr ""
|
||||
msgstr "Uporabi KJ"
|
||||
|
||||
#: .\cookbook\forms.py:57
|
||||
msgid "Theme"
|
||||
msgstr ""
|
||||
msgstr "Tema"
|
||||
|
||||
#: .\cookbook\forms.py:58
|
||||
msgid "Navbar color"
|
||||
msgstr ""
|
||||
msgstr "Barva navigacijske vrstice"
|
||||
|
||||
#: .\cookbook\forms.py:59
|
||||
msgid "Sticky navbar"
|
||||
msgstr ""
|
||||
msgstr "Lepljiva navigacijska vrstica"
|
||||
|
||||
#: .\cookbook\forms.py:60
|
||||
msgid "Default page"
|
||||
msgstr ""
|
||||
msgstr "Privzeta stran"
|
||||
|
||||
#: .\cookbook\forms.py:61
|
||||
msgid "Show recent recipes"
|
||||
msgstr ""
|
||||
msgstr "Prikaži nedavne recepte"
|
||||
|
||||
#: .\cookbook\forms.py:62
|
||||
msgid "Search style"
|
||||
msgstr ""
|
||||
msgstr "Vrsta iskalnika"
|
||||
|
||||
#: .\cookbook\forms.py:63
|
||||
msgid "Plan sharing"
|
||||
msgstr ""
|
||||
msgstr "Deli planer"
|
||||
|
||||
#: .\cookbook\forms.py:64
|
||||
msgid "Ingredient decimal places"
|
||||
msgstr ""
|
||||
msgstr "Decimalno mesto pri sestavini"
|
||||
|
||||
#: .\cookbook\forms.py:65
|
||||
msgid "Shopping list auto sync period"
|
||||
msgstr ""
|
||||
msgstr "Čas avtomatske sinhronizacije pri nakupovalnem listku"
|
||||
|
||||
#: .\cookbook\forms.py:66 .\cookbook\templates\recipe_view.html:21
|
||||
#: .\cookbook\templates\space.html:62 .\cookbook\templates\stats.html:47
|
||||
@@ -85,38 +85,44 @@ msgid ""
|
||||
"Color of the top navigation bar. Not all colors work with all themes, just "
|
||||
"try them out!"
|
||||
msgstr ""
|
||||
"Barva zgornje vrstice za krmarjenje. Ne delujejo vse barve z vsemi temami!"
|
||||
|
||||
#: .\cookbook\forms.py:72
|
||||
msgid "Default Unit to be used when inserting a new ingredient into a recipe."
|
||||
msgstr ""
|
||||
"Privzeta enota, ki se uporablja pri vstavljanju nove sestavine v recept."
|
||||
|
||||
#: .\cookbook\forms.py:74
|
||||
msgid ""
|
||||
"Enables support for fractions in ingredient amounts (e.g. convert decimals "
|
||||
"to fractions automatically)"
|
||||
msgstr ""
|
||||
"Omogoča podporo ulomkom/frakcijam v količinah sestavin (npr. samodejno "
|
||||
"pretvori decimalke v ulomke)"
|
||||
|
||||
#: .\cookbook\forms.py:76
|
||||
msgid "Display nutritional energy amounts in joules instead of calories"
|
||||
msgstr ""
|
||||
msgstr "Prikazuj hranilne energijske količine v joules namesto v kalorijah"
|
||||
|
||||
#: .\cookbook\forms.py:78
|
||||
msgid ""
|
||||
"Users with whom newly created meal plan/shopping list entries should be "
|
||||
"shared by default."
|
||||
msgstr ""
|
||||
"Uporabniki, s katerimi je privzeto deljen novo ustvarjen načrt ali "
|
||||
"nakupovalni listek."
|
||||
|
||||
#: .\cookbook\forms.py:80
|
||||
msgid "Show recently viewed recipes on search page."
|
||||
msgstr ""
|
||||
msgstr "Prikaži nedavno videne recepte na iskalniku."
|
||||
|
||||
#: .\cookbook\forms.py:81
|
||||
msgid "Number of decimals to round ingredients."
|
||||
msgstr ""
|
||||
msgstr "Število decimalk, ki so zaokrožene pri sestavinah."
|
||||
|
||||
#: .\cookbook\forms.py:82
|
||||
msgid "If you want to be able to create and see comments underneath recipes."
|
||||
msgstr ""
|
||||
msgstr "V primeru, da želite ustvariti in videti komentarje pod recepti."
|
||||
|
||||
#: .\cookbook\forms.py:84
|
||||
msgid ""
|
||||
@@ -125,21 +131,28 @@ msgid ""
|
||||
"Useful when shopping with multiple people but might use a little bit of "
|
||||
"mobile data. If lower than instance limit it is reset when saving."
|
||||
msgstr ""
|
||||
"Nastavitev na 0 bo onemogočila avtomatsko sinhronizacijo. V pogledu "
|
||||
"nakupovalnega listka, se seznam osvežuje vsake toliko sekund, če nekdo drug "
|
||||
"naredi spremembo. To je najbolj uporabno, če nakupovalni listek delimo z "
|
||||
"večimi osebami. Paziti je potrebno, saj porabi nekaj podatkov v mobilnem "
|
||||
"omrežju."
|
||||
|
||||
#: .\cookbook\forms.py:87
|
||||
msgid "Makes the navbar stick to the top of the page."
|
||||
msgstr ""
|
||||
msgstr "Nastavi navigacijsko vrstico na vrh strani."
|
||||
|
||||
#: .\cookbook\forms.py:103
|
||||
msgid ""
|
||||
"Both fields are optional. If none are given the username will be displayed "
|
||||
"instead"
|
||||
msgstr ""
|
||||
"Obe polji sta opcijski. V primeru, da ju pustimo prazni bo prikazano "
|
||||
"uporabniško ime"
|
||||
|
||||
#: .\cookbook\forms.py:124 .\cookbook\forms.py:289
|
||||
#: .\cookbook\templates\url_import.html:158
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
msgstr "Ime"
|
||||
|
||||
#: .\cookbook\forms.py:125 .\cookbook\forms.py:290
|
||||
#: .\cookbook\templates\space.html:39 .\cookbook\templates\stats.html:24
|
||||
@@ -150,47 +163,51 @@ msgstr "Ključne besede"
|
||||
|
||||
#: .\cookbook\forms.py:126
|
||||
msgid "Preparation time in minutes"
|
||||
msgstr ""
|
||||
msgstr "Priprava v minutah"
|
||||
|
||||
#: .\cookbook\forms.py:127
|
||||
msgid "Waiting time (cooking/baking) in minutes"
|
||||
msgstr ""
|
||||
msgstr "Čas čakanja v minutah"
|
||||
|
||||
#: .\cookbook\forms.py:128 .\cookbook\forms.py:259 .\cookbook\forms.py:291
|
||||
msgid "Path"
|
||||
msgstr ""
|
||||
msgstr "Pot"
|
||||
|
||||
#: .\cookbook\forms.py:129
|
||||
msgid "Storage UID"
|
||||
msgstr ""
|
||||
msgstr "UID shrambe"
|
||||
|
||||
#: .\cookbook\forms.py:157
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Privzeto"
|
||||
|
||||
#: .\cookbook\forms.py:168 .\cookbook\templates\url_import.html:94
|
||||
msgid ""
|
||||
"To prevent duplicates recipes with the same name as existing ones are "
|
||||
"ignored. Check this box to import everything."
|
||||
msgstr ""
|
||||
"V primeru, da želite preprečiti dvojnike receptov z enakim imenom kot so "
|
||||
"obstoječi. Če želite uvoziti vse, potrdite to polje."
|
||||
|
||||
#: .\cookbook\forms.py:190
|
||||
msgid "Add your comment: "
|
||||
msgstr ""
|
||||
msgstr "Dodaj komentar: "
|
||||
|
||||
#: .\cookbook\forms.py:205
|
||||
msgid "Leave empty for dropbox and enter app password for nextcloud."
|
||||
msgstr ""
|
||||
msgstr "Pusti prazno za dropbox in vnesi geslo za nextcloud."
|
||||
|
||||
#: .\cookbook\forms.py:212
|
||||
msgid "Leave empty for nextcloud and enter api token for dropbox."
|
||||
msgstr ""
|
||||
msgstr "Pusti prazno za nextcloud in vnesi API žeton za dropbox."
|
||||
|
||||
#: .\cookbook\forms.py:221
|
||||
msgid ""
|
||||
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
|
||||
"php/webdav/</code> is added automatically)"
|
||||
msgstr ""
|
||||
"Pusti prazno za dropbox in vnesi URL za nextcloud (<code>/remote.php/webdav/"
|
||||
"</code> je dodano avtomatsko)"
|
||||
|
||||
#: .\cookbook\forms.py:258 .\cookbook\views\edit.py:166
|
||||
msgid "Storage"
|
||||
@@ -198,33 +215,35 @@ msgstr "Shramba"
|
||||
|
||||
#: .\cookbook\forms.py:260
|
||||
msgid "Active"
|
||||
msgstr ""
|
||||
msgstr "Aktivno"
|
||||
|
||||
#: .\cookbook\forms.py:265
|
||||
msgid "Search String"
|
||||
msgstr ""
|
||||
msgstr "Iskalni niz"
|
||||
|
||||
#: .\cookbook\forms.py:292
|
||||
msgid "File ID"
|
||||
msgstr ""
|
||||
msgstr "ID datoteke"
|
||||
|
||||
#: .\cookbook\forms.py:313
|
||||
msgid "You must provide at least a recipe or a title."
|
||||
msgstr ""
|
||||
msgstr "Vpisati moraš vsaj recept ali naslov."
|
||||
|
||||
#: .\cookbook\forms.py:326
|
||||
msgid "You can list default users to share recipes with in the settings."
|
||||
msgstr ""
|
||||
msgstr "Seznam uporabnikov za deljenje receptov lahko vidiš v nastavitvah."
|
||||
|
||||
#: .\cookbook\forms.py:327
|
||||
msgid ""
|
||||
"You can use markdown to format this field. See the <a href=\"/docs/markdown/"
|
||||
"\">docs here</a>"
|
||||
msgstr ""
|
||||
"Lahko uporabiš \"markdown\", da urediš to polje. Preveri <a href=\"/docs/"
|
||||
"markdown/\">tukaj</a>"
|
||||
|
||||
#: .\cookbook\forms.py:353
|
||||
msgid "Maximum number of users for this space reached."
|
||||
msgstr ""
|
||||
msgstr "Maksimalno število uporabnikov za ta prostor je doseženo."
|
||||
|
||||
#: .\cookbook\forms.py:359
|
||||
msgid "Email address already taken!"
|
||||
@@ -235,56 +254,71 @@ msgid ""
|
||||
"An email address is not required but if present the invite link will be sent "
|
||||
"to the user."
|
||||
msgstr ""
|
||||
"E-poštni naslov ni potreben, vendar če je vnešeno, bo povabilo poslano do "
|
||||
"uporabnika."
|
||||
|
||||
#: .\cookbook\forms.py:382
|
||||
msgid "Name already taken."
|
||||
msgstr ""
|
||||
msgstr "Ime je že zasedeno."
|
||||
|
||||
#: .\cookbook\forms.py:393
|
||||
msgid "Accept Terms and Privacy"
|
||||
msgstr ""
|
||||
msgstr "Sprejmi pogoje uporabe"
|
||||
|
||||
#: .\cookbook\forms.py:425
|
||||
msgid ""
|
||||
"Determines how fuzzy a search is if it uses trigram similarity matching (e."
|
||||
"g. low values mean more typos are ignored)."
|
||||
msgstr ""
|
||||
"Določa, kakšno je iskanje, če uporablja trigram podobnost ujemanje (npr. "
|
||||
"nizke vrednosti pomenijo več, tipkanje se prezre)."
|
||||
|
||||
#: .\cookbook\forms.py:435
|
||||
msgid ""
|
||||
"Select type method of search. Click <a href=\"/docs/search/\">here</a> for "
|
||||
"full desciption of choices."
|
||||
msgstr ""
|
||||
"Izberi metodo iskanja. Klikni <a href=\"/docs/search/\">tukaj</a> za "
|
||||
"prikaz vseh izbir."
|
||||
|
||||
#: .\cookbook\forms.py:436
|
||||
msgid ""
|
||||
"Use fuzzy matching on units, keywords and ingredients when editing and "
|
||||
"importing recipes."
|
||||
msgstr ""
|
||||
"Pri urejanju in uvozu receptov uporabite mehka ujemanja na enotah, ključnih "
|
||||
"besedah in sestavinah."
|
||||
|
||||
#: .\cookbook\forms.py:438
|
||||
msgid ""
|
||||
"Fields to search ignoring accents. Selecting this option can improve or "
|
||||
"degrade search quality depending on language"
|
||||
msgstr ""
|
||||
"Polja za iskanje prezrtih naglasov. Če izberete to možnost, lahko izboljšate "
|
||||
"ali poslabšate kakovost iskanja, odvisno od jezika"
|
||||
|
||||
#: .\cookbook\forms.py:440
|
||||
msgid ""
|
||||
"Fields to search for partial matches. (e.g. searching for 'Pie' will return "
|
||||
"'pie' and 'piece' and 'soapie')"
|
||||
msgstr ""
|
||||
"Polja za iskanje delnih ujemajev. (npr. iskanje \"Pie\" vrne \"pie\" in "
|
||||
"\"piece\" in \"soapie\")"
|
||||
|
||||
#: .\cookbook\forms.py:442
|
||||
msgid ""
|
||||
"Fields to search for beginning of word matches. (e.g. searching for 'sa' "
|
||||
"will return 'salad' and 'sandwich')"
|
||||
msgstr ""
|
||||
"Polja za iskanje začetka ujemanja besed. (npr. iskanje \"sa\" vrne \"salad\" "
|
||||
"in \"sandwich\")"
|
||||
|
||||
#: .\cookbook\forms.py:444
|
||||
msgid ""
|
||||
"Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) "
|
||||
"Note: this option will conflict with 'web' and 'raw' methods of search."
|
||||
msgstr ""
|
||||
"Polja za \"mehko\" iskanje. (npr. iskanje \"recpie\" bo našlo \"recipe\".)"
|
||||
|
||||
#: .\cookbook\forms.py:446
|
||||
msgid ""
|
||||
|
||||
@@ -10,9 +10,10 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='show_facet_count',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
# migrations.AddField(
|
||||
# model_name='space',
|
||||
# name='show_facet_count',
|
||||
# field=models.BooleanField(default=False),
|
||||
# ),
|
||||
# removed due to quick fix in 0159 migration to maintain correct order
|
||||
]
|
||||
|
||||
20
cookbook/migrations/0168_add_unit_searchfields.py
Normal file
20
cookbook/migrations/0168_add_unit_searchfields.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations
|
||||
|
||||
from cookbook.models import SearchFields
|
||||
|
||||
|
||||
def create_searchfields(apps, schema_editor):
|
||||
SearchFields.objects.create(name='Units', field='steps__ingredients__unit__name')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0167_userpreference_left_handed'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
create_searchfields
|
||||
),
|
||||
]
|
||||
34
cookbook/migrations/0169_exportlog.py
Normal file
34
cookbook/migrations/0169_exportlog.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 3.2.11 on 2022-02-03 15:03
|
||||
|
||||
import cookbook.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0168_add_unit_searchfields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ExportLog',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('type', models.CharField(max_length=32)),
|
||||
('running', models.BooleanField(default=True)),
|
||||
('msg', models.TextField(default='')),
|
||||
('total_recipes', models.IntegerField(default=0)),
|
||||
('exported_recipes', models.IntegerField(default=0)),
|
||||
('cache_duration', models.IntegerField(default=0)),
|
||||
('possibly_not_expired', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
|
||||
],
|
||||
bases=(models.Model, cookbook.models.PermissionModelMixin),
|
||||
),
|
||||
]
|
||||
@@ -609,7 +609,7 @@ class NutritionInformation(models.Model, PermissionModelMixin):
|
||||
)
|
||||
proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
calories = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
source = models.CharField( max_length=512, default="", null=True, blank=True)
|
||||
source = models.CharField(max_length=512, default="", null=True, blank=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
@@ -852,11 +852,12 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
|
||||
def __str__(self):
|
||||
return f'Shopping list entry {self.id}'
|
||||
|
||||
# TODO deprecate
|
||||
def get_shared(self):
|
||||
return self.shoppinglist_set.first().shared.all()
|
||||
try:
|
||||
return self.shoppinglist_set.first().shared.all()
|
||||
except AttributeError:
|
||||
return self.created_by.userpreference.shopping_share.all()
|
||||
|
||||
# TODO deprecate
|
||||
def get_owner(self):
|
||||
try:
|
||||
return self.created_by or self.shoppinglist_set.first().created_by
|
||||
@@ -881,6 +882,12 @@ class ShoppingList(ExportModelOperationsMixin('shopping_list'), models.Model, Pe
|
||||
def __str__(self):
|
||||
return f'Shopping list {self.id}'
|
||||
|
||||
def get_shared(self):
|
||||
try:
|
||||
return self.shared.all() or self.created_by.userpreference.shopping_share.all()
|
||||
except AttributeError:
|
||||
return []
|
||||
|
||||
|
||||
class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
@@ -995,6 +1002,25 @@ class ImportLog(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return f"{self.created_at}:{self.type}"
|
||||
|
||||
class ExportLog(models.Model, PermissionModelMixin):
|
||||
type = models.CharField(max_length=32)
|
||||
running = models.BooleanField(default=True)
|
||||
msg = models.TextField(default="")
|
||||
|
||||
total_recipes = models.IntegerField(default=0)
|
||||
exported_recipes = models.IntegerField(default=0)
|
||||
cache_duration = models.IntegerField(default=0)
|
||||
possibly_not_expired = models.BooleanField(default=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
objects = ScopedManager(space='space')
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.created_at}:{self.type}"
|
||||
|
||||
|
||||
class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models.Model, PermissionModelMixin):
|
||||
html = models.TextField()
|
||||
|
||||
@@ -13,9 +13,9 @@ from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.fields import empty
|
||||
|
||||
from cookbook.helper.HelperFunctions import str2bool
|
||||
from cookbook.helper.shopping_helper import list_from_recipe
|
||||
from cookbook.helper.shopping_helper import RecipeShoppingEditor
|
||||
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
|
||||
FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType,
|
||||
FoodInheritField, ImportLog, ExportLog, Ingredient, Keyword, MealPlan, MealType,
|
||||
NutritionInformation, Recipe, RecipeBook, RecipeBookEntry,
|
||||
RecipeImport, ShareLink, ShoppingList, ShoppingListEntry,
|
||||
ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
|
||||
@@ -33,7 +33,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
|
||||
images = None
|
||||
|
||||
image = serializers.SerializerMethodField('get_image')
|
||||
numrecipe = serializers.ReadOnlyField(source='count_recipes_test')
|
||||
numrecipe = serializers.ReadOnlyField(source='recipe_count')
|
||||
|
||||
def get_fields(self, *args, **kwargs):
|
||||
fields = super().get_fields(*args, **kwargs)
|
||||
@@ -58,9 +58,6 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
|
||||
if obj.recipe_image:
|
||||
return MEDIA_URL + obj.recipe_image
|
||||
|
||||
def count_recipes(self, obj):
|
||||
return Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).count()
|
||||
|
||||
|
||||
class CustomDecimalField(serializers.Field):
|
||||
"""
|
||||
@@ -161,7 +158,7 @@ class FoodInheritFieldSerializer(WritableNestedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = FoodInheritField
|
||||
fields = ('id', 'name', 'field', )
|
||||
fields = ('id', 'name', 'field',)
|
||||
read_only_fields = ['id']
|
||||
|
||||
|
||||
@@ -169,6 +166,11 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
|
||||
food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, required=False, read_only=True)
|
||||
plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True)
|
||||
shopping_share = UserNameSerializer(many=True, allow_null=True, required=False)
|
||||
food_children_exist = serializers.SerializerMethodField('get_food_children_exist')
|
||||
|
||||
def get_food_children_exist(self, obj):
|
||||
space = getattr(self.context.get('request', None), 'space', None)
|
||||
return Food.objects.filter(depth__gt=0, space=space).exists()
|
||||
|
||||
def create(self, validated_data):
|
||||
if not validated_data.get('user', None):
|
||||
@@ -180,10 +182,10 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = (
|
||||
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_kj', 'search_style', 'show_recent', 'plan_share',
|
||||
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj', 'search_style', 'show_recent', 'plan_share',
|
||||
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
|
||||
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix',
|
||||
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed'
|
||||
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'food_children_exist'
|
||||
)
|
||||
|
||||
|
||||
@@ -429,7 +431,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
model = Food
|
||||
fields = (
|
||||
'id', 'name', 'description', 'shopping', 'recipe', 'food_onhand', 'supermarket_category',
|
||||
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name'
|
||||
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping'
|
||||
)
|
||||
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
|
||||
|
||||
@@ -658,7 +660,8 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
mealplan = super().create(validated_data)
|
||||
if self.context['request'].data.get('addshopping', False):
|
||||
list_from_recipe(mealplan=mealplan, servings=validated_data['servings'], created_by=validated_data['created_by'], space=validated_data['space'])
|
||||
SLR = RecipeShoppingEditor(user=validated_data['created_by'], space=validated_data['space'])
|
||||
SLR.create(mealplan=mealplan, servings=validated_data['servings'])
|
||||
return mealplan
|
||||
|
||||
class Meta:
|
||||
@@ -690,13 +693,10 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
|
||||
) + f' ({value:.2g})'
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if 'servings' in validated_data:
|
||||
list_from_recipe(
|
||||
list_recipe=instance,
|
||||
servings=validated_data['servings'],
|
||||
created_by=self.context['request'].user,
|
||||
space=self.context['request'].space
|
||||
)
|
||||
# TODO remove once old shopping list
|
||||
if 'servings' in validated_data and self.context.get('view', None).__class__.__name__ != 'ShoppingListViewSet':
|
||||
SLR = RecipeShoppingEditor(user=self.context['request'].user, space=self.context['request'].space)
|
||||
SLR.edit_servings(servings=validated_data['servings'], id=instance.id)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
@@ -726,9 +726,9 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
|
||||
def run_validation(self, data):
|
||||
if self.root.instance.__class__.__name__ == 'ShoppingListEntry':
|
||||
if (
|
||||
data.get('checked', False)
|
||||
and self.root.instance
|
||||
and not self.root.instance.checked
|
||||
data.get('checked', False)
|
||||
and self.root.instance
|
||||
and not self.root.instance.checked
|
||||
):
|
||||
# if checked flips from false to true set completed datetime
|
||||
data['completed_at'] = timezone.now()
|
||||
@@ -764,7 +764,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
|
||||
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan',
|
||||
'created_by', 'created_at', 'completed_at', 'delay_until'
|
||||
)
|
||||
read_only_fields = ('id', 'created_by', 'created_at',)
|
||||
read_only_fields = ('id', 'created_by', 'created_at',)
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
@@ -850,6 +850,20 @@ class ImportLogSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
|
||||
class ExportLogSerializer(serializers.ModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
validated_data['space'] = self.context['request'].space
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = ExportLog
|
||||
fields = ('id', 'type', 'msg', 'running', 'total_recipes', 'exported_recipes', 'cache_duration', 'possibly_not_expired', 'created_by', 'created_at')
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
|
||||
|
||||
class AutomationSerializer(serializers.ModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
|
||||
@@ -6,8 +6,9 @@ from django.contrib.postgres.search import SearchVector
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import translation
|
||||
from django_scopes import scope
|
||||
|
||||
from cookbook.helper.shopping_helper import list_from_recipe
|
||||
from cookbook.helper.shopping_helper import RecipeShoppingEditor
|
||||
from cookbook.managers import DICTIONARY
|
||||
from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe,
|
||||
ShoppingListEntry, Step)
|
||||
@@ -104,28 +105,31 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs):
|
||||
|
||||
@receiver(post_save, sender=MealPlan)
|
||||
def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs):
|
||||
if not instance:
|
||||
return
|
||||
user = instance.get_owner()
|
||||
if not user.userpreference.mealplan_autoadd_shopping:
|
||||
with scope(space=instance.space):
|
||||
slr_exists = instance.shoppinglistrecipe_set.exists()
|
||||
|
||||
if not created and slr_exists:
|
||||
for x in instance.shoppinglistrecipe_set.all():
|
||||
# assuming that permissions checks for the MealPlan have happened upstream
|
||||
if instance.servings != x.servings:
|
||||
SLR = RecipeShoppingEditor(id=x.id, user=user, space=instance.space)
|
||||
SLR.edit_servings(servings=instance.servings)
|
||||
# list_recipe = list_from_recipe(list_recipe=x, servings=instance.servings, space=instance.space)
|
||||
elif not user.userpreference.mealplan_autoadd_shopping or not instance.recipe:
|
||||
return
|
||||
|
||||
if not created and instance.shoppinglistrecipe_set.exists():
|
||||
for x in instance.shoppinglistrecipe_set.all():
|
||||
if instance.servings != x.servings:
|
||||
list_recipe = list_from_recipe(list_recipe=x, servings=instance.servings, space=instance.space)
|
||||
elif created:
|
||||
if created:
|
||||
# if creating a mealplan - perform shopping list activities
|
||||
kwargs = {
|
||||
'mealplan': instance,
|
||||
'space': instance.space,
|
||||
'created_by': user,
|
||||
'servings': instance.servings
|
||||
}
|
||||
list_recipe = list_from_recipe(**kwargs)
|
||||
# kwargs = {
|
||||
# 'mealplan': instance,
|
||||
# 'space': instance.space,
|
||||
# 'created_by': user,
|
||||
# 'servings': instance.servings
|
||||
# }
|
||||
SLR = RecipeShoppingEditor(user=user, space=instance.space)
|
||||
SLR.create(mealplan=instance, servings=instance.servings)
|
||||
|
||||
|
||||
# user = self.context['request'].user
|
||||
# if user.userpreference.shopping_add_onhand:
|
||||
# if checked := validated_data.get('checked', None):
|
||||
# instance.food.onhand_users.add(*user.userpreference.shopping_share.all(), user)
|
||||
# elif checked == False:
|
||||
# instance.food.onhand_users.remove(*user.userpreference.shopping_share.all(), user)
|
||||
# list_recipe = list_from_recipe(**kwargs)
|
||||
|
||||
7
cookbook/static/css/app.min.css
vendored
7
cookbook/static/css/app.min.css
vendored
@@ -1140,3 +1140,10 @@
|
||||
min-width: 28rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media print{
|
||||
#switcher{
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,25 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% load crispy_forms_filters %}
|
||||
{% load i18n %}
|
||||
{% load render_bundle from webpack_loader %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
|
||||
|
||||
{% block title %}{% trans 'Export Recipes' %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ form.media }}
|
||||
|
||||
{% block content %}
|
||||
<div id="app">
|
||||
<export-view></export-view>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block script %}
|
||||
{% if debug %}
|
||||
<script src="{% url 'js_reverse' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/javascript">
|
||||
window.EXPORT_ID = {{pk}};
|
||||
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}';
|
||||
</script>
|
||||
|
||||
{% render_bundle 'export_view' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<h2>{% trans 'Export' %}</h2>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<form action="." method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-success" type="submit"><i class="fas fa-file-export"></i> {% trans 'Export' %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
32
cookbook/templates/export_response.html
Normal file
32
cookbook/templates/export_response.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% load render_bundle from webpack_loader %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
|
||||
{% block title %}{% trans 'Export' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="app">
|
||||
<export-response-view></export-response-view>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block script %}
|
||||
{% if debug %}
|
||||
<script src="{% url 'js_reverse' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/javascript">
|
||||
window.EXPORT_ID = {{pk}};
|
||||
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
|
||||
</script>
|
||||
|
||||
{% render_bundle 'export_response_view' %}
|
||||
{% endblock %}
|
||||
@@ -1,81 +0,0 @@
|
||||
{% load i18n %}
|
||||
{% comment %} TODO: Deprecate {% endcomment %}
|
||||
|
||||
<div class="modal" tabindex="-1" role="dialog" id="id_modal_cook_log">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{% trans 'Log Recipe Cooking' %}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{% trans 'All fields are optional and can be left empty.' %}</p>
|
||||
<form>
|
||||
|
||||
<label for="id_log_servings">{% trans 'Servings' %} </label>
|
||||
<input class="form-control" type="number" id="id_log_servings">
|
||||
<br/>
|
||||
<label for="id_log_rating">{% trans 'Rating' %} - <span id="id_rating_show">0/5</span></label>
|
||||
<input type="range" class="custom-range" min="0" max="5" id="id_log_rating" name="log_rating"
|
||||
value="0">
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">{% trans 'Close' %}</button>
|
||||
<button type="button" class="btn btn-primary" onclick="logCook()">{% trans 'Save' %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="application/javascript">
|
||||
|
||||
function openCookLogModal(id) {
|
||||
let modal = $('#id_modal_cook_log')
|
||||
modal.data('recipe_id', id)
|
||||
modal.modal('show')
|
||||
}
|
||||
|
||||
//TODO there is definitely a nicer way to do this than this ugly shit
|
||||
function logCook() {
|
||||
let modal = $('#id_modal_cook_log')
|
||||
let rating = $('#id_log_rating')
|
||||
let id = modal.data('recipe_id');
|
||||
|
||||
let url = "{% url 'api_log_cooking' recipe_id=12345 %}".replace(/12345/, id);
|
||||
|
||||
let val_servings = $('#id_log_servings').val()
|
||||
if (val_servings !== '' && val_servings !== 0) {
|
||||
url += '?s=' + val_servings
|
||||
}
|
||||
|
||||
let val_rating = rating.val()
|
||||
if (val_rating !== '' && val_rating !== 0) {
|
||||
if (val_servings !== '' && val_servings !== 0) {
|
||||
url += '&'
|
||||
} else {
|
||||
url += '?'
|
||||
}
|
||||
url += 'r=' + val_rating
|
||||
}
|
||||
|
||||
let request = new XMLHttpRequest();
|
||||
request.onreadystatechange = function () {
|
||||
|
||||
};
|
||||
request.open("GET", url, true);
|
||||
request.send();
|
||||
|
||||
modal.modal('hide')
|
||||
}
|
||||
|
||||
$('#id_log_rating').on("input", () => {
|
||||
let rating = $('#id_log_rating')
|
||||
$('#id_rating_show').html(rating.val() + '/5')
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -94,6 +94,5 @@
|
||||
{% trans "Log in to view recipes" %} <br/>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include 'include/log_cooking.html' %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -217,13 +217,13 @@
|
||||
</div>
|
||||
|
||||
<script type="application/javascript">
|
||||
$(function () {
|
||||
$(function() {
|
||||
$('#id_search-trigram_threshold').get(0).type = 'range';
|
||||
})
|
||||
});
|
||||
|
||||
function applyPreset (preset){
|
||||
$('#id_search-preset').val(preset)
|
||||
$('#search_form_button').click()
|
||||
function applyPreset(preset) {
|
||||
$('#id_search-preset').val(preset);
|
||||
$('#search_form_button').click();
|
||||
}
|
||||
|
||||
function copyToken() {
|
||||
@@ -239,29 +239,30 @@
|
||||
}
|
||||
|
||||
// Change hash for page-reload
|
||||
$('.nav-tabs a').on('shown.bs.tab', function (e) {
|
||||
$('.nav-tabs a').on('shown.bs.tab', function(e) {
|
||||
window.location.hash = e.target.hash;
|
||||
})
|
||||
|
||||
{% comment %}
|
||||
// listen for events
|
||||
{% comment %} $(document).ready(function(){
|
||||
$(document).ready(function() {
|
||||
hideShow()
|
||||
// call hideShow when the user clicks on the mealplan_autoadd checkbox
|
||||
$("#id_shopping-mealplan_autoadd_shopping").click(function(event){
|
||||
hideShow()
|
||||
$("#id_shopping-mealplan_autoadd_shopping").click(function(event) {
|
||||
hideShow();
|
||||
});
|
||||
})
|
||||
|
||||
function hideShow(){
|
||||
if(document.getElementById('id_shopping-mealplan_autoadd_shopping').checked == true)
|
||||
{
|
||||
$('#div_id_shopping-mealplan_autoexclude_onhand').show();
|
||||
$('#div_id_shopping-mealplan_autoinclude_related').show();
|
||||
}
|
||||
else
|
||||
{
|
||||
function hideShow() {
|
||||
if(document.getElementById('id_shopping-mealplan_autoadd_shopping').checked == true) {
|
||||
$('#div_id_shopping-mealplan_autoexclude_onhand').show();
|
||||
$('#div_id_shopping-mealplan_autoinclude_related').show();
|
||||
}
|
||||
else {
|
||||
$('#div_id_shopping-mealplan_autoexclude_onhand').hide();
|
||||
$('#div_id_shopping-mealplan_autoinclude_related').hide();
|
||||
} {% endcomment %}
|
||||
}
|
||||
}
|
||||
{% endcomment %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -834,7 +834,7 @@
|
||||
this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe.id)).then((response) => {
|
||||
for (let s of response.data.steps) {
|
||||
for (let i of s.ingredients) {
|
||||
if (!i.is_header && i.food !== null && i.food.food_onhand === false) {
|
||||
if (!i.is_header && i.food !== null && !i.food.ignore_food) {
|
||||
this.shopping_list.entries.push({
|
||||
'list_recipe': slr.id,
|
||||
'food': i.food,
|
||||
|
||||
@@ -156,6 +156,32 @@ def test_sharing(request, shared, count, sle_2, sle, u1_s1):
|
||||
# confirm shared user sees their list and the list that's shared with them
|
||||
assert len(json.loads(r.content)) == count
|
||||
|
||||
# test shared user can mark complete
|
||||
x = shared_client.patch(
|
||||
reverse(DETAIL_URL, args={sle[0].id}),
|
||||
{'checked': True},
|
||||
content_type='application/json'
|
||||
)
|
||||
r = json.loads(shared_client.get(reverse(LIST_URL)).content)
|
||||
assert len(r) == count
|
||||
# count unchecked entries
|
||||
if not x.status_code == 404:
|
||||
count = count-1
|
||||
assert [x['checked'] for x in r].count(False) == count
|
||||
# test shared user can delete
|
||||
x = shared_client.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={sle[1].id}
|
||||
)
|
||||
)
|
||||
r = json.loads(shared_client.get(reverse(LIST_URL)).content)
|
||||
assert len(r) == count
|
||||
# count unchecked entries
|
||||
if not x.status_code == 404:
|
||||
count = count-1
|
||||
assert [x['checked'] for x in r].count(False) == count
|
||||
|
||||
|
||||
def test_completed(sle, u1_s1):
|
||||
# check 1 entry
|
||||
|
||||
@@ -164,7 +164,7 @@ def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u
|
||||
assert len(r) == sle_count
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
|
||||
|
||||
# test removing 2 items from shopping list
|
||||
# test removing 3 items from shopping list
|
||||
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
|
||||
{'list_recipe': list_recipe, 'ingredients': keep_ing},
|
||||
content_type='application/json'
|
||||
|
||||
25
cookbook/tests/other/test_export.py
Normal file
25
cookbook/tests/other/test_export.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
|
||||
from cookbook.forms import ImportExportBase
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.models import ExportLog
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def obj_1(space_1, u1_s1):
|
||||
return ExportLog.objects.create(type=ImportExportBase.DEFAULT, running=False, created_by=auth.get_user(u1_s1), space=space_1, exported_recipes=10, total_recipes=10)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 302],
|
||||
['g1_s1', 302],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 200],
|
||||
['u1_s2', 404],
|
||||
['a1_s2', 404],
|
||||
])
|
||||
def test_export_file_cache(arg, request, obj_1):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
assert c.get(reverse('view_export_file', args=[obj_1.pk])).status_code == arg[1]
|
||||
@@ -21,6 +21,7 @@ router.register(r'cook-log', api.CookLogViewSet)
|
||||
router.register(r'food', api.FoodViewSet)
|
||||
router.register(r'food-inherit-field', api.FoodInheritFieldViewSet)
|
||||
router.register(r'import-log', api.ImportLogViewSet)
|
||||
router.register(r'export-log', api.ExportLogViewSet)
|
||||
router.register(r'ingredient', api.IngredientViewSet)
|
||||
router.register(r'keyword', api.KeywordViewSet)
|
||||
router.register(r'meal-plan', api.MealPlanViewSet)
|
||||
@@ -74,6 +75,8 @@ urlpatterns = [
|
||||
path('import/', import_export.import_recipe, name='view_import'),
|
||||
path('import-response/<int:pk>/', import_export.import_response, name='view_import_response'),
|
||||
path('export/', import_export.export_recipe, name='view_export'),
|
||||
path('export-response/<int:pk>/', import_export.export_response, name='view_export_response'),
|
||||
path('export-file/<int:pk>/', import_export.export_file, name='view_export_file'),
|
||||
|
||||
path('view/recipe/<int:pk>', views.recipe_view, name='view_recipe'),
|
||||
path('view/recipe/<int:pk>/<slug:share>', views.recipe_view, name='view_recipe'),
|
||||
|
||||
@@ -41,9 +41,9 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus
|
||||
from cookbook.helper.recipe_html_import import get_recipe_from_source
|
||||
from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch, old_search
|
||||
from cookbook.helper.recipe_url_import import get_from_scraper
|
||||
from cookbook.helper.shopping_helper import list_from_recipe, shopping_helper
|
||||
from cookbook.helper.shopping_helper import RecipeShoppingEditor, shopping_helper
|
||||
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField,
|
||||
ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook,
|
||||
ImportLog, ExportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook,
|
||||
RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry,
|
||||
ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
|
||||
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
|
||||
@@ -54,7 +54,7 @@ from cookbook.provider.nextcloud import Nextcloud
|
||||
from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema
|
||||
from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer,
|
||||
CookLogSerializer, FoodInheritFieldSerializer, FoodSerializer,
|
||||
FoodShoppingUpdateSerializer, ImportLogSerializer,
|
||||
FoodShoppingUpdateSerializer, ImportLogSerializer, ExportLogSerializer,
|
||||
IngredientSerializer, KeywordSerializer, MealPlanSerializer,
|
||||
MealTypeSerializer, RecipeBookEntrySerializer,
|
||||
RecipeBookSerializer, RecipeImageSerializer,
|
||||
@@ -118,7 +118,7 @@ class ExtendedRecipeMixin():
|
||||
# add a recipe count annotation to the query
|
||||
# explanation on construction https://stackoverflow.com/a/43771738/15762829
|
||||
recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values(recipe_filter).annotate(count=Count('pk')).values('count')
|
||||
queryset = queryset.annotate(recipe_count_test=Coalesce(Subquery(recipe_count), 0))
|
||||
queryset = queryset.annotate(recipe_count=Coalesce(Subquery(recipe_count), 0))
|
||||
|
||||
# add a recipe image annotation to the query
|
||||
image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
|
||||
@@ -153,11 +153,15 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
|
||||
)
|
||||
else:
|
||||
# TODO have this check unaccent search settings or other search preferences?
|
||||
filter = Q(name__icontains=query)
|
||||
if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
|
||||
filter |= Q(name__unaccent__icontains=query)
|
||||
|
||||
self.queryset = (
|
||||
self.queryset
|
||||
.annotate(starts=Case(When(name__istartswith=query, then=(Value(100))),
|
||||
default=Value(0))) # put exact matches at the top of the result set
|
||||
.filter(name__icontains=query).order_by('-starts', 'name')
|
||||
.filter(filter).order_by('-starts', 'name')
|
||||
)
|
||||
|
||||
updated_at = self.request.query_params.get('updated_at', None)
|
||||
@@ -400,7 +404,7 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
permission_classes = [CustomIsUser]
|
||||
|
||||
def get_queryset(self):
|
||||
self.queryset = self.queryset.filter(space=self.request.space)
|
||||
self.queryset = self.queryset.filter(space=self.request.space).order_by('name')
|
||||
return super().get_queryset()
|
||||
|
||||
|
||||
@@ -644,7 +648,6 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
schema = QueryParamAutoSchema()
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
if self.detail:
|
||||
self.queryset = self.queryset.filter(space=self.request.space)
|
||||
return super().get_queryset()
|
||||
@@ -717,16 +720,27 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
obj = self.get_object()
|
||||
ingredients = request.data.get('ingredients', None)
|
||||
servings = request.data.get('servings', None)
|
||||
list_recipe = ShoppingListRecipe.objects.filter(id=request.data.get('list_recipe', None)).first()
|
||||
if servings is None:
|
||||
servings = getattr(list_recipe, 'servings', obj.servings)
|
||||
# created_by needs to be sticky to original creator as it is 'their' shopping list
|
||||
# changing shopping list created_by can shift some items to new owner which may not share in the other direction
|
||||
created_by = getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', request.user)
|
||||
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
|
||||
list_from_recipe(list_recipe=list_recipe, recipe=obj, ingredients=ingredients, servings=servings, space=request.space, created_by=created_by)
|
||||
list_recipe = request.data.get('list_recipe', None)
|
||||
mealplan = request.data.get('mealplan', None)
|
||||
SLR = RecipeShoppingEditor(request.user, request.space, id=list_recipe, recipe=obj, mealplan=mealplan)
|
||||
|
||||
return Response(content, status=status.HTTP_204_NO_CONTENT)
|
||||
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
|
||||
http_status = status.HTTP_204_NO_CONTENT
|
||||
if servings and servings <= 0:
|
||||
result = SLR.delete()
|
||||
elif list_recipe:
|
||||
result = SLR.edit(servings=servings, ingredients=ingredients)
|
||||
else:
|
||||
result = SLR.create(servings=servings, ingredients=ingredients)
|
||||
|
||||
if not result:
|
||||
content = {'msg': ('An error occurred')}
|
||||
http_status = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
else:
|
||||
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
|
||||
http_status = status.HTTP_204_NO_CONTENT
|
||||
|
||||
return Response(content, status=http_status)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
@@ -847,6 +861,17 @@ class ImportLogViewSet(viewsets.ModelViewSet):
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
|
||||
|
||||
class ExportLogViewSet(viewsets.ModelViewSet):
|
||||
queryset = ExportLog.objects
|
||||
serializer_class = ExportLogSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
|
||||
|
||||
|
||||
class BookmarkletImportViewSet(viewsets.ModelViewSet):
|
||||
queryset = BookmarkletImport.objects
|
||||
serializer_class = BookmarkletImportSerializer
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import re
|
||||
import threading
|
||||
from io import BytesIO
|
||||
from django.core.cache import cache
|
||||
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@@ -29,7 +30,7 @@ from cookbook.integration.recipesage import RecipeSage
|
||||
from cookbook.integration.rezkonv import RezKonv
|
||||
from cookbook.integration.saffron import Saffron
|
||||
from cookbook.integration.pdfexport import PDFexport
|
||||
from cookbook.models import Recipe, ImportLog, UserPreference
|
||||
from cookbook.models import Recipe, ImportLog, ExportLog, UserPreference
|
||||
from recipes import settings
|
||||
|
||||
|
||||
@@ -123,25 +124,57 @@ def export_recipe(request):
|
||||
if form.cleaned_data['all']:
|
||||
recipes = Recipe.objects.filter(space=request.space, internal=True).all()
|
||||
|
||||
if form.cleaned_data['type'] == ImportExportBase.PDF and not settings.ENABLE_PDF_EXPORT:
|
||||
messages.add_message(request, messages.ERROR, _('The PDF Exporter is not enabled on this instance as it is still in an experimental state.'))
|
||||
return render(request, 'export.html', {'form': form})
|
||||
integration = get_integration(request, form.cleaned_data['type'])
|
||||
return integration.do_export(recipes)
|
||||
except NotImplementedError:
|
||||
messages.add_message(request, messages.ERROR, _('Exporting is not implemented for this provider'))
|
||||
|
||||
if form.cleaned_data['type'] == ImportExportBase.PDF and not settings.ENABLE_PDF_EXPORT:
|
||||
return JsonResponse({'error': _('The PDF Exporter is not enabled on this instance as it is still in an experimental state.')})
|
||||
|
||||
el = ExportLog.objects.create(type=form.cleaned_data['type'], created_by=request.user, space=request.space)
|
||||
|
||||
t = threading.Thread(target=integration.do_export, args=[recipes, el])
|
||||
t.setDaemon(True)
|
||||
t.start()
|
||||
|
||||
return JsonResponse({'export_id': el.pk})
|
||||
except NotImplementedError:
|
||||
return JsonResponse(
|
||||
{
|
||||
'error': True,
|
||||
'msg': _('Importing is not implemented for this provider')
|
||||
},
|
||||
status=400
|
||||
)
|
||||
else:
|
||||
form = ExportForm(space=request.space)
|
||||
pk = ''
|
||||
recipe = request.GET.get('r')
|
||||
if recipe:
|
||||
if re.match(r'^([0-9])+$', recipe):
|
||||
if recipe := Recipe.objects.filter(pk=int(recipe), space=request.space).first():
|
||||
form = ExportForm(initial={'recipes': recipe}, space=request.space)
|
||||
pk = Recipe.objects.filter(pk=int(recipe), space=request.space).first().pk
|
||||
|
||||
return render(request, 'export.html', {'form': form})
|
||||
return render(request, 'export.html', {'pk': pk})
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def import_response(request, pk):
|
||||
return render(request, 'import_response.html', {'pk': pk})
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def export_response(request, pk):
|
||||
return render(request, 'export_response.html', {'pk': pk})
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def export_file(request, pk):
|
||||
el = get_object_or_404(ExportLog, pk=pk, space=request.space)
|
||||
|
||||
cacheData = cache.get(f'export_file_{el.pk}')
|
||||
|
||||
if cacheData is None:
|
||||
el.possibly_not_expired = False
|
||||
el.save()
|
||||
return render(request, 'export_response.html', {'pk': pk})
|
||||
|
||||
response = HttpResponse(cacheData['file'], content_type='application/force-download')
|
||||
response['Content-Disposition'] = 'attachment; filename="' + cacheData['filename'] + '"'
|
||||
return response
|
||||
|
||||
@@ -260,7 +260,7 @@ def shopping_list(request, pk=None): # TODO deprecate
|
||||
recipes = []
|
||||
for r in html_list:
|
||||
r = r.replace('[', '').replace(']', '')
|
||||
if re.match(r'^([0-9])+,([0-9])+[.]*([0-9])*$', r): # vulnerable to DoS
|
||||
if len(r) < 10000 and re.match(r'^([0-9])+,([0-9])+[.]*([0-9])*$', r):
|
||||
rid, multiplier = r.split(',')
|
||||
if recipe := Recipe.objects.filter(pk=int(rid), space=request.space).first():
|
||||
recipes.append({'recipe': recipe.id, 'multiplier': multiplier})
|
||||
|
||||
15
docs/faq.md
15
docs/faq.md
@@ -18,6 +18,17 @@ Open Tandoor, open the menu behind the three vertical dots at the top right, sel
|
||||
#### Microsoft Edge
|
||||
Open Tandoor, open the menu behind the three horizontal dots at the top right, select `Apps > Install Tandoor Recipes`
|
||||
|
||||
## Why is Tandoor not working correctly?
|
||||
If you just set up your Tandoor instance and you're having issues like...
|
||||
|
||||
- Links not working
|
||||
- CSRF errors
|
||||
- CORS errors
|
||||
- No recipes are loading
|
||||
|
||||
... then make sure, that you have set [all required headers](install/docker.md#required-headers) in your reverse proxy correctly.
|
||||
If that doesn't fix it, you can also refer to the appropriate sub section in the [reverse proxy documentation](install/docker.md#reverse-proxy) and verify your general webserver configuration.
|
||||
|
||||
## Why am I getting CSRF Errors?
|
||||
If you are getting CSRF Errors this is most likely due to a reverse proxy not passing the correct headers.
|
||||
|
||||
@@ -34,6 +45,10 @@ The other common issue is that the recommended nginx container is removed from t
|
||||
If removed, the nginx webserver needs to be replaced by something else that servers the /mediafiles/ directory or
|
||||
`GUNICORN_MEDIA` needs to be enabled to allow media serving by the application container itself.
|
||||
|
||||
## Why is Tandoor not working on my Raspberry Pi?
|
||||
|
||||
Please refer to [here](install/docker.md#setup-issues-on-raspberry-pi).
|
||||
|
||||
## How can I create users?
|
||||
To create a new user click on your name (top right corner) and select system. There click on invite links and create a new invite link.
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ AUTH_LDAP_USER_SEARCH_FILTER_STR=(uid=%(user)s)
|
||||
AUTH_LDAP_USER_ATTR_MAP={'first_name': 'givenName', 'last_name': 'sn', 'email': 'mail'}
|
||||
AUTH_LDAP_ALWAYS_UPDATE_USER=1
|
||||
AUTH_LDAP_CACHE_TIMEOUT=3600
|
||||
AUTH_LDAP_TLS_CACERTFILE=/etc/ssl/certs/own-ca.pem
|
||||
```
|
||||
|
||||
## Reverse Proxy Authentication
|
||||
|
||||
@@ -21,26 +21,26 @@ if your favorite one is missing.
|
||||
Overview of the capabilities of the different integrations.
|
||||
|
||||
| Integration | Import | Export | Images |
|
||||
|--------------------| ------ | ------ | ------ |
|
||||
| Default | ✔️ | ✔️ | ✔️ |
|
||||
| Nextcloud | ✔️ | ⌚ | ✔️ |
|
||||
| Mealie | ✔️ | ⌚ | ✔️ |
|
||||
| Chowdown | ✔️ | ⌚ | ✔️ |
|
||||
| Safron | ✔️ | ✔ | ❌ |
|
||||
| Paprika | ✔️ | ⌚ | ✔️ |
|
||||
| ChefTap | ✔️ | ❌ | ❌ |
|
||||
| Pepperplate | ✔️ | ⌚ | ❌ |
|
||||
| RecipeSage | ✔️ | ✔️ | ✔️ |
|
||||
| Domestica | ✔️ | ⌚ | ✔️ |
|
||||
| MealMaster | ✔️ | ❌ | ❌ |
|
||||
| RezKonv | ✔️ | ❌ | ❌ |
|
||||
| OpenEats | ✔️ | ❌ | ⌚ |
|
||||
| Plantoeat | ✔️ | ❌ | ✔ |
|
||||
| CookBookApp | ✔️ | ⌚ | ✔️ |
|
||||
| CopyMeThat | ✔️ | ❌ | ✔️ |
|
||||
| PDF (experimental) | ⌚️ | ✔ | ✔️ |
|
||||
|--------------------| ------ | -- | ------ |
|
||||
| Default | ✔️ | ✔️ | ✔️ |
|
||||
| Nextcloud | ✔️ | ⌚ | ✔️ |
|
||||
| Mealie | ✔️ | ⌚ | ✔️ |
|
||||
| Chowdown | ✔️ | ⌚ | ✔️ |
|
||||
| Safron | ✔️ | ✔️ | ❌ |
|
||||
| Paprika | ✔️ | ⌚ | ✔️ |
|
||||
| ChefTap | ✔️ | ❌ | ❌ |
|
||||
| Pepperplate | ✔️ | ⌚ | ❌ |
|
||||
| RecipeSage | ✔️ | ✔️ | ✔️ |
|
||||
| Domestica | ✔️ | ⌚ | ✔️ |
|
||||
| MealMaster | ✔️ | ❌ | ❌ |
|
||||
| RezKonv | ✔️ | ❌ | ❌ |
|
||||
| OpenEats | ✔️ | ❌ | ⌚ |
|
||||
| Plantoeat | ✔️ | ❌ | ✔ |
|
||||
| CookBookApp | ✔️ | ⌚ | ✔️ |
|
||||
| CopyMeThat | ✔️ | ❌ | ✔️ |
|
||||
| PDF (experimental) | ⌚️ | ✔️ | ✔️ |
|
||||
|
||||
✔ = implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented
|
||||
✔️ = implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented
|
||||
|
||||
## Default
|
||||
The default integration is the built in (and preferred) way to import and export recipes.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
!!! success "Recommended Installation"
|
||||
Setting up this application using Docker is recommended. This does not mean that other options are bad, just that
|
||||
support is much easier for this setup.
|
||||
Setting up this application using Docker is recommended. This does not mean that other options are bad, just that
|
||||
support is much easier for this setup.
|
||||
|
||||
It is possible to install this application using many Docker configurations.
|
||||
It is possible to install this application using many different Docker configurations.
|
||||
|
||||
Please read the instructions/notes on each example carefully and decide if this is the way for you.
|
||||
Please read the instructions on each example carefully and decide if this is the way for you.
|
||||
|
||||
## Docker
|
||||
## **Docker**
|
||||
|
||||
The docker image (`vabene1111/recipes`) simply exposes the application on the container's port `8080`.
|
||||
|
||||
@@ -32,75 +32,84 @@ Please make sure, if you run your image this way, to consult
|
||||
the [.env.template](https://raw.githubusercontent.com/vabene1111/recipes/master/.env.template)
|
||||
file in the GitHub repository to verify if additional environment variables are required for your setup.
|
||||
|
||||
### Versions
|
||||
Also, don't forget to replace the placeholders for ```SECRET_KEY``` and ```POSTGRES_PASSWORD```!
|
||||
|
||||
There are different versions (tags) released on docker hub.
|
||||
## **Versions**
|
||||
|
||||
There are different versions (tags) released on [Docker Hub](https://hub.docker.com/r/vabene1111/recipes/tags).
|
||||
|
||||
- **latest** Default image. The one you should use if you don't know that you need anything else.
|
||||
- **beta** Partially stable version that gets updated every now and then. Expect to have some problems.
|
||||
- **develop** If you want the most bleeding edge version with potentially many breaking changes feel free to use this version (I don't recommend it!).
|
||||
- **develop** If you want the most bleeding edge version with potentially many breaking changes feel free to use this version (not recommended!).
|
||||
- **X.Y.Z** each released version has its own image. If you need to revert to an old version or want to make sure you stay on one specific use these tags.
|
||||
|
||||
!!! danger "No Downgrading"
|
||||
There is currently no way to migrate back to an older version as there is no mechanism to downgrade the database.
|
||||
You could probably do it but I cannot help you with that. Choose wisely if you want to use the unstable images.
|
||||
That said **beta** should usually be working if you like frequent updates and new stuff.
|
||||
There is currently no way to migrate back to an older version as there is no mechanism to downgrade the database.
|
||||
You could probably do it but I cannot help you with that. Choose wisely if you want to use the unstable images.
|
||||
That said **beta** should usually be working if you like frequent updates and new stuff.
|
||||
|
||||
## Docker Compose
|
||||
## **Docker Compose**
|
||||
|
||||
The main, and also recommended, installation option is to install this application using Docker Compose.
|
||||
The main, and also recommended, installation option for this application is Docker Compose.
|
||||
|
||||
1. Choose your `docker-compose.yml` from the examples below.
|
||||
2. Download the `.env` configuration file with `wget`, then **edit it accordingly** (you NEED to set `SECRET_KEY` and `POSTGRES_PASSWORD`).
|
||||
```shell
|
||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env
|
||||
```
|
||||
3. Start your container using `docker-compose up -d`.
|
||||
2. Download the `.env` configuration file with `wget`
|
||||
```shell
|
||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env
|
||||
```
|
||||
3. **Edit it accordingly** (you NEED to set `SECRET_KEY` and `POSTGRES_PASSWORD`).
|
||||
4. Start your container using `docker-compose up -d`.
|
||||
|
||||
### Plain
|
||||
### **Plain**
|
||||
|
||||
This configuration exposes the application through an nginx web server on port 80 of your machine.
|
||||
This configuration exposes the application through a containerized nginx web server on port 80 of your machine.
|
||||
Be aware that having some other web server or container running on your host machine on port 80 will block this from working.
|
||||
|
||||
```shell
|
||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/plain/docker-compose.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
{ % include "./docker/plain/docker-compose.yml" % }
|
||||
```
|
||||
~~~yaml
|
||||
{% include "./docker/plain/docker-compose.yml" %}
|
||||
~~~
|
||||
|
||||
### Reverse Proxy
|
||||
!!!note
|
||||
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
|
||||
|
||||
### **Reverse Proxy**
|
||||
|
||||
Most deployments will likely use a reverse proxy.
|
||||
|
||||
If your reverse proxy is not listed here, please refer to [Others](https://docs.tandoor.dev/install/docker/#others).
|
||||
If your reverse proxy is not listed below, please refer to chapter [Others](#others).
|
||||
|
||||
#### Traefik
|
||||
#### **Traefik**
|
||||
|
||||
If you use traefik, this configuration is the one for you.
|
||||
If you use Traefik, this configuration is the one for you.
|
||||
|
||||
!!! info
|
||||
Traefik can be a little confusing to setup.
|
||||
Please refer to [their excellent documentation](https://doc.traefik.io/traefik/). If that does not help,
|
||||
[this little example](traefik.md) might be for you.
|
||||
Traefik can be a little confusing to setup.
|
||||
Please refer to [their excellent documentation](https://doc.traefik.io/traefik/). If that does not help,
|
||||
[this little example](traefik.md) might be for you.
|
||||
|
||||
```shell
|
||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/traefik-nginx/docker-compose.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
{ % include "./docker/traefik-nginx/docker-compose.yml" % }
|
||||
```
|
||||
~~~yaml
|
||||
{% include "./docker/traefik-nginx/docker-compose.yml" %}
|
||||
~~~
|
||||
|
||||
#### nginx-proxy
|
||||
!!!note
|
||||
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
|
||||
|
||||
#### **jwilder's Nginx-proxy**
|
||||
|
||||
This is a docker compose example using [jwilder's nginx reverse proxy](https://github.com/jwilder/docker-gen)
|
||||
in combination with [jrcs's letsencrypt companion](https://hub.docker.com/r/jrcs/letsencrypt-nginx-proxy-companion/).
|
||||
|
||||
Please refer to the appropriate documentation on how to setup the reverse proxy and networks.
|
||||
|
||||
Remember to add the appropriate environment variables to `.env` file:
|
||||
Remember to add the appropriate environment variables to the `.env` file:
|
||||
|
||||
```
|
||||
VIRTUAL_HOST=
|
||||
@@ -112,11 +121,14 @@ LETSENCRYPT_EMAIL=
|
||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/nginx-proxy/docker-compose.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
{ % include "./docker/nginx-proxy/docker-compose.yml" % }
|
||||
```
|
||||
~~~yaml
|
||||
{% include "./docker/nginx-proxy/docker-compose.yml" %}
|
||||
~~~
|
||||
|
||||
#### Nginx Swag by LinuxServer
|
||||
!!!note
|
||||
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
|
||||
|
||||
#### **Nginx Swag by LinuxServer**
|
||||
|
||||
[This container](https://github.com/linuxserver/docker-swag) is an all in one solution created by LinuxServer.io.
|
||||
|
||||
@@ -140,15 +152,114 @@ Please refer to the [appropriate documentation](https://github.com/linuxserver/d
|
||||
|
||||
For step-by-step instructions to set this up from scratch, see [this example](swag.md).
|
||||
|
||||
### Others
|
||||
#### **Pure Nginx**
|
||||
|
||||
If you use none of the above mentioned reverse proxies or want to use an existing one on your host machine (like a local nginx or Caddy), simply use the [PLAIN](https://docs.tandoor.dev/install/docker/#plain) setup above and change the outbound port to one of your liking.
|
||||
If you have Nginx installed locally on your host system without using any third party integration like Swag or similar, this is for you.
|
||||
|
||||
You can use the Docker-Compose file from [Plain](#plain).
|
||||
!!!warning "Adjust Docker-Compose file"
|
||||
Replace `80:80` with `PORT:80` with PORT being your desired outward-facing port.
|
||||
In the nginx config example below, 8080 is used.
|
||||
|
||||
An example configuration with LetsEncrypt to get you started can be seen below.
|
||||
Please note, that since every setup is different, you might need to adjust some things.
|
||||
|
||||
!!!warning "Placeholders"
|
||||
Don't forget to replace the domain and port.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
if ($host = recipes.mydomain.tld) { # replace domain
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server_name recipes.mydomain.tld; # replace domain
|
||||
listen 80;
|
||||
return 404;
|
||||
}
|
||||
server {
|
||||
server_name recipes.mydomain.tld; # replace domain
|
||||
listen 443 ssl;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/recipes.mydomain.tld/fullchain.pem; # replace domain
|
||||
ssl_certificate_key /etc/letsencrypt/live/recipes.mydomain.tld/privkey.pem; # replace domain
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
location / {
|
||||
proxy_set_header Host $http_host; # try $host instead if this doesn't work
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://127.0.0.1:8080; # replace port
|
||||
proxy_redirect http://127.0.0.1:8080 https://recipes.domain.tld; # replace port and domain
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
!!!note
|
||||
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
|
||||
|
||||
#### **Apache**
|
||||
|
||||
You can use the Docker-Compose file from [Plain](#plain).
|
||||
!!!warning "Adjust Docker-Compose file"
|
||||
Replace `80:80` with `PORT:80` with PORT being your desired outward-facing port.
|
||||
In the Apache config example below, 8080 is used.
|
||||
|
||||
If you use e.g. LetsEncrypt for SSL encryption, you can use the example configuration from [solaris7590](https://github.com/TandoorRecipes/recipes/issues/1312#issuecomment-1020034375) below.
|
||||
|
||||
!!!warning "Placeholders"
|
||||
Don't forget to replace the domain and port.
|
||||
|
||||
```apache
|
||||
<IfModule mod_ssl.c>
|
||||
<VirtualHost *:80>
|
||||
ServerAdmin webmaster@mydomain.de # replace domain
|
||||
ServerName mydomain.de # replace domain
|
||||
|
||||
Redirect permanent / https://mydomain.de/ # replace domain
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerAdmin webmaster@mydomain.de # replace domain
|
||||
ServerName mydomain.de # replace domain
|
||||
|
||||
SSLEngine on
|
||||
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
Header always set Access-Control-Allow-Origin "*"
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
ProxyPass / http://localhost:8080/ # replace port
|
||||
ProxyPassReverse / http://localhost:8080/ # replace port
|
||||
|
||||
SSLCertificateFile /etc/letsencrypt/live/mydomain.de/fullchain.pem # replace domain/path
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/mydomain.de/privkey.pem # replace domain/path
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/recipes_error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/recipes_access.log combined
|
||||
</VirtualHost>
|
||||
</IfModule>
|
||||
```
|
||||
|
||||
If you're having issues with the example configuration above, you can try [beedaddy](https://github.com/TandoorRecipes/recipes/issues/1312#issuecomment-1015252663)'s example config.
|
||||
|
||||
!!!note
|
||||
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
|
||||
|
||||
#### **Others**
|
||||
|
||||
If you use none of the above mentioned reverse proxies or want to use an existing one on your host machine (like a local nginx or Caddy), simply use the [Plain](#plain) setup above and change the outbound port to one of your liking.
|
||||
|
||||
An example port config (inside the respective docker-compose.yml) would be: `8123:80` instead of the `80:80` or if you want to be sure, that Tandoor is **just** accessible via your proxy and don't wanna bother with your firewall, then `127.0.0.1:8123:80` is a viable option too.
|
||||
|
||||
## Additional Information
|
||||
!!!note
|
||||
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
|
||||
|
||||
### Nginx vs Gunicorn
|
||||
## **Additional Information**
|
||||
|
||||
### **Nginx vs Gunicorn**
|
||||
|
||||
All examples use an additional `nginx` container to serve mediafiles and act as the forward facing webserver.
|
||||
This is **technically not required** but **very much recommended**.
|
||||
@@ -158,20 +269,20 @@ the WSGi server that handles the Python execution, explicitly state that it is n
|
||||
You will also likely not see any decrease in performance or a lot of space used as nginx is a very light container.
|
||||
|
||||
!!! info
|
||||
Even if you run behind a reverse proxy as described above, using an additional nginx container is the recommended option.
|
||||
Even if you run behind a reverse proxy as described above, using an additional nginx container is the recommended option.
|
||||
|
||||
If you run a small private deployment and don't care about performance, security and whatever else feel free to run
|
||||
without a ngix container.
|
||||
without a nginx container.
|
||||
|
||||
!!! warning
|
||||
When running without nginx make sure to enable `GUNICORN_MEDIA` in the `.env`. Without it, media files will be uploaded
|
||||
but not shown on the page.
|
||||
When running without nginx make sure to enable `GUNICORN_MEDIA` in the `.env`. Without it, media files will be uploaded
|
||||
but not shown on the page.
|
||||
|
||||
For additional information please refer to the [0.9.0 Release](https://github.com/vabene1111/recipes/releases?after=0.9.0)
|
||||
and [Issue 201](https://github.com/vabene1111/recipes/issues/201) where these topics have been discussed.
|
||||
See also refer to the [official gunicorn docs](https://docs.gunicorn.org/en/stable/deploy.html).
|
||||
|
||||
### Nginx Config
|
||||
### **Nginx Config**
|
||||
|
||||
In order to give the user (you) the greatest amount of freedom when choosing how to deploy this application the
|
||||
webserver is not directly bundled with the Docker image.
|
||||
@@ -186,14 +297,54 @@ to the host system and from there into the nginx container.
|
||||
This is not really a clean solution, but I could not find any better alternative that provided the same amount of
|
||||
usability. If you know of any better way, feel free to open an issue.
|
||||
|
||||
### Volumes vs Bind Mounts
|
||||
### **Volumes vs Bind Mounts**
|
||||
|
||||
Since I personally prefer to have my data where my `docker-compose.yml` resides, bind mounts are used in the example
|
||||
configuration files for all user generated data (e.g. Postgresql and media files).
|
||||
|
||||
Please note that [there is a difference in functionality](https://docs.docker.com/storage/volumes/)
|
||||
between the two and you cannot always simply interchange them.
|
||||
!!!warning
|
||||
Please note that [there is a difference in functionality](https://docs.docker.com/storage/volumes/)
|
||||
between the two and you cannot always simply interchange them.
|
||||
|
||||
You can move everything to volumes if you prefer it this way, **but you cannot convert the nginx config file to a bind
|
||||
mount.**
|
||||
If you do so you will have to manually create the nginx config file and restart the container once after creating it.
|
||||
|
||||
### **Required Headers**
|
||||
|
||||
Please be sure to supply all required headers in your nginx/Apache/Caddy/... configuration!
|
||||
|
||||
nginx:
|
||||
```nginx
|
||||
location / {
|
||||
proxy_set_header Host $http_host; # try $host instead if this doesn't work
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://127.0.0.1:8080; # replace port
|
||||
proxy_redirect http://127.0.0.1:8080 https://recipes.domain.tld; # replace port and domain
|
||||
}
|
||||
```
|
||||
|
||||
Apache:
|
||||
```apache
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
Header always set Access-Control-Allow-Origin "*"
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
ProxyPass / http://localhost:8080/ # replace port
|
||||
ProxyPassReverse / http://localhost:8080/ # replace port
|
||||
```
|
||||
|
||||
### **Setup issues on Raspberry Pi**
|
||||
|
||||
!!!info
|
||||
Always wait at least 2-3 minutes after the very first start, since migrations will take some time!
|
||||
|
||||
If you're having issues with installing Tandoor on your Raspberry Pi or similar device,
|
||||
follow these instructions:
|
||||
|
||||
- Stop all Tandoor containers (`docker-compose down`)
|
||||
- Delete local database folder (usually 'postgresql' in the same folder as your 'docker-compose.yml' file)
|
||||
- Start Tandoor containers again (`docker-compose up -d`)
|
||||
- Wait for at least 2-3 minutes and then check if everything is working now (migrations can take quite some time!)
|
||||
- If not, check logs of the web_recipes container with `docker logs <container_name>` and make sure that all migrations are indeed already done
|
||||
@@ -9,6 +9,11 @@ services:
|
||||
- ./.env
|
||||
networks:
|
||||
- default
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "psql -U $$POSTGRES_USER -d $$POSTGRES_DB --list || exit 1"]
|
||||
interval: 4s
|
||||
timeout: 1s
|
||||
retries: 12
|
||||
|
||||
web_recipes:
|
||||
image: vabene1111/recipes
|
||||
@@ -20,7 +25,8 @@ services:
|
||||
- nginx_config:/opt/recipes/nginx/conf.d
|
||||
- ./mediafiles:/opt/recipes/mediafiles
|
||||
depends_on:
|
||||
- db_recipes
|
||||
db_recipes:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- default
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ services:
|
||||
- ./postgresql:/var/lib/postgresql/data
|
||||
env_file:
|
||||
- ./.env
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "psql -U $$POSTGRES_USER -d $$POSTGRES_DB --list || exit 1"]
|
||||
interval: 4s
|
||||
timeout: 1s
|
||||
retries: 12
|
||||
|
||||
web_recipes:
|
||||
image: vabene1111/recipes
|
||||
@@ -18,7 +23,8 @@ services:
|
||||
- nginx_config:/opt/recipes/nginx/conf.d
|
||||
- ./mediafiles:/opt/recipes/mediafiles
|
||||
depends_on:
|
||||
- db_recipes
|
||||
db_recipes:
|
||||
condition: service_healthy
|
||||
|
||||
nginx_recipes:
|
||||
image: nginx:mainline-alpine
|
||||
@@ -36,4 +42,4 @@ services:
|
||||
|
||||
volumes:
|
||||
nginx_config:
|
||||
staticfiles:
|
||||
staticfiles:
|
||||
|
||||
@@ -9,6 +9,11 @@ services:
|
||||
- ./.env
|
||||
networks:
|
||||
- default
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "psql -U $$POSTGRES_USER -d $$POSTGRES_DB --list || exit 1"]
|
||||
interval: 4s
|
||||
timeout: 1s
|
||||
retries: 12
|
||||
|
||||
web_recipes:
|
||||
image: vabene1111/recipes
|
||||
@@ -20,7 +25,8 @@ services:
|
||||
- nginx_config:/opt/recipes/nginx/conf.d
|
||||
- ./mediafiles:/opt/recipes/mediafiles
|
||||
depends_on:
|
||||
- db_recipes
|
||||
db_recipes:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- default
|
||||
|
||||
@@ -51,4 +57,4 @@ networks:
|
||||
|
||||
volumes:
|
||||
nginx_config:
|
||||
staticfiles:
|
||||
staticfiles:
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
**!!! info "Community Contributed" This guide was contributed by the community and is neither officially supported, nor updated or tested.**
|
||||
!!! info "Community Contributed"
|
||||
This guide was contributed by the community and is neither officially supported, nor updated or tested.
|
||||
|
||||
# K8s Setup
|
||||
## K8s Setup
|
||||
|
||||
This is a setup which should be sufficient for production use. Be sure to replace the default secrets!
|
||||
|
||||
# Files
|
||||
## Files
|
||||
|
||||
## 10-configmap.yaml
|
||||
### 10-configmap.yaml
|
||||
|
||||
The nginx config map. This is loaded as nginx.conf in the nginx sidecar to configure nginx to deliver static content.
|
||||
|
||||
## 15-secrets.yaml
|
||||
### 15-secrets.yaml
|
||||
|
||||
The secrets **replace them!!** This file is only here for a quick start. Be aware that changing secrets after installation will be messy and is not documented here. **You should set new secrets before the installation.** As you are reading this document **before** the installation ;-)
|
||||
!!! warning "Contains secrets"
|
||||
**Replace them!**
|
||||
|
||||
Create your own postgresql passwords and the secret key for the django app
|
||||
This file is only here for a quick start. Be aware that changing secrets after installation will be messy and is not documented here. **You should set new secrets before the installation.** As you are reading this document **before** the installation ;-)
|
||||
|
||||
see also [Managing Secrets using kubectl](https://kubernetes.io/docs/tasks/configmap-secret/managing-secret-using-kubectl/)
|
||||
Create your own postgresql passwords and the secret key for the django app.
|
||||
|
||||
See also [Managing Secrets using kubectl](https://kubernetes.io/docs/tasks/configmap-secret/managing-secret-using-kubectl/)
|
||||
|
||||
**Replace** `db-password`, `postgres-user-password` and `secret-key` **with something - well - secret :-)**
|
||||
|
||||
@@ -35,37 +39,37 @@ kubectl create secret generic recipes \
|
||||
--from-file=secret-key=./secret-key.txt
|
||||
~~~
|
||||
|
||||
## 20-service-account.yml
|
||||
### 20-service-account.yml
|
||||
|
||||
Creating service account `recipes` for deployment and stateful set.
|
||||
|
||||
## 30-pvc.yaml
|
||||
### 30-pvc.yaml
|
||||
|
||||
The creation of the persistent volume claims for media and static content. May you want to increase the size. This expects to have a storage class installed.
|
||||
|
||||
## 40-sts-postgresql.yaml
|
||||
### 40-sts-postgresql.yaml
|
||||
|
||||
The PostgreSQL stateful set, based on a bitnami image. It runs a init container as root to do the preparations. The postgres container itself runs as a lower privileged user. The recipes app uses the database super user (postgres) as the recipes app is doing some db migrations on startup, which needs super user privileges.
|
||||
|
||||
## 45-service-db.yaml
|
||||
### 45-service-db.yaml
|
||||
|
||||
Creating the database service.
|
||||
|
||||
## 50-deployment.yaml
|
||||
### 50-deployment.yaml
|
||||
|
||||
The deployment first fires up a init container to do the database migrations and file modifications. This init container runs as root. The init container runs part of the [boot.sh](https://github.com/TandoorRecipes/recipes/blob/develop/boot.sh) script from the `vabene1111/recipes` image.
|
||||
|
||||
The deployment then runs two containers, the recipes-nginx and the recipes container which runs the gunicorn app. The nginx container gets it's nginx.conf via config map to deliver static content `/static` and `/media`. The guincorn container gets it's secret key and the database password from the secret `recipes`. `gunicorn` runs as user `nobody`.
|
||||
|
||||
## 60-service.yaml
|
||||
### 60-service.yaml
|
||||
|
||||
Creating the app service.
|
||||
|
||||
## 70-ingress.yaml
|
||||
### 70-ingress.yaml
|
||||
|
||||
Setting up the ingress for the recipes service. Requests for static content `/static` and `/media` are send to the nginx container, everything else to gunicorn. TLS setup via cert-manager is prepared. You have to **change the host** from `recipes.local` to your specific domain.
|
||||
|
||||
# Conclusion
|
||||
## Conclusion
|
||||
|
||||
All in all:
|
||||
|
||||
@@ -80,16 +84,16 @@ I tried the setup with [kind](https://kind.sigs.k8s.io/) and it runs well on my
|
||||
|
||||
There is a warning, when you check your system as super user:
|
||||
|
||||
**Media Serving Warning**
|
||||
Serving media files directly using gunicorn/python is not recommend! Please follow the steps described here to update your installation.
|
||||
!!! warning "Media Serving Warning"
|
||||
Serving media files directly using gunicorn/python is not recommend! Please follow the steps described here to update your installation.
|
||||
|
||||
I don't know how this check works, but this warning is simply wrong! ;-) Media and static files are routed by ingress to the nginx container - I promise :-)
|
||||
|
||||
# Updates
|
||||
## Updates
|
||||
|
||||
These manifests are tested against Release 1.0.1. Newer versions may not work without changes.
|
||||
|
||||
# Apply the manifets
|
||||
## Apply the manifets
|
||||
|
||||
To apply the manifest with kubectl, use the following command:
|
||||
|
||||
|
||||
@@ -21,25 +21,29 @@ Create virtual env: `python3.9 -m venv /var/www/recipes`
|
||||
|
||||
Install Javascript Tools
|
||||
```shell
|
||||
apt install nodejs
|
||||
npm install --global yarn
|
||||
sudo apt install nodejs
|
||||
sudo npm install --global yarn
|
||||
```
|
||||
|
||||
### Install postgresql requirements
|
||||
|
||||
`sudo apt install libpq-dev postgresql`
|
||||
```shell
|
||||
sudo apt install libpq-dev postgresql
|
||||
```
|
||||
|
||||
###Install project requirements
|
||||
|
||||
!!! warning "Update"
|
||||
Dependencies change with most updates so the following steps need to be re-run with every update or else the application might stop working.
|
||||
See section **Updating** below
|
||||
See section [Updating](#updating) below.
|
||||
|
||||
Using binaries from the virtual env:
|
||||
|
||||
`/var/www/recipes/bin/pip3.9 install -r requirements.txt`
|
||||
```shell
|
||||
/var/www/recipes/bin/pip3.9 install -r requirements.txt
|
||||
```
|
||||
|
||||
You will also need to install front end requirements and build them. For this navigate to the `./vue`folder and run
|
||||
You will also need to install front end requirements and build them. For this navigate to the `./vue` folder and run
|
||||
|
||||
```shell
|
||||
yarn install
|
||||
@@ -48,7 +52,9 @@ yarn build
|
||||
|
||||
## Setup postgresql
|
||||
|
||||
`sudo -u postgres psql`
|
||||
```shell
|
||||
sudo -u postgres psql
|
||||
```
|
||||
|
||||
In the psql console:
|
||||
|
||||
@@ -73,6 +79,7 @@ wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template
|
||||
```
|
||||
|
||||
Things to edit:
|
||||
|
||||
- `SECRET_KEY`: use something secure.
|
||||
- `POSTGRES_HOST`: probably 127.0.0.1.
|
||||
- `POSTGRES_PASSWORD`: the password we set earlier when setting up djangodb.
|
||||
|
||||
@@ -5,19 +5,18 @@ Many people appear to host this application on their Synology NAS. The following
|
||||
@therealschimmi in [this issue discussion](https://github.com/vabene1111/recipes/issues/98#issuecomment-643062907).
|
||||
|
||||
There is also this
|
||||
([word](https://github.com/vabene1111/recipes/files/6708738/Tandoor.on.a.Synology.Disk.Station.docx),
|
||||
([word](https://github.com/vabene1111/recipes/files/6708738/Tandoor.on.a.Synology.Disk.Station.docx),
|
||||
[pdf](https://github.com/vabene1111/recipes/files/6901601/Tandoor.on.a.Synology.Disk.Station.pdf)) awesome and
|
||||
very detailed guide provided by @DiversityBug.
|
||||
|
||||
There are, as always, most likely other ways to do this but this can be used as a starting point for your
|
||||
setup. Since I cannot test it myself feedback and improvements are always very welcome.
|
||||
|
||||
## Instructions
|
||||
## **Instructions**
|
||||
|
||||
Basic guide to setup `vabenee1111/recipes docker` container on Synology NAS
|
||||
|
||||
1. Login to Synology DSM through your browser
|
||||
Basic guide to setup `vabenee1111/recipes` docker container on Synology NAS.
|
||||
|
||||
### 1. Login to Synology DSM through your browser
|
||||
- Install Docker through package center
|
||||
- Optional: Create a shared folder for your docker projects, they have to store data somewhere outside the containers
|
||||
- Create a folder somewhere, I suggest naming it 'recipes' and storing it in the dedicated docker folder
|
||||
@@ -25,28 +24,30 @@ Basic guide to setup `vabenee1111/recipes docker` container on Synology NAS
|
||||
|
||||

|
||||
|
||||
2. Download templates
|
||||
- vabene1111 gives you a few samples for various setups to work with. I chose to use the plain setup for now.
|
||||
- Open https://github.com/vabene1111/recipes/tree/develop/docs/install/docker
|
||||
- Download docker-compose.yml to your recipes folder
|
||||
- Open https://github.com/vabene1111/recipes/tree/develop/nginx/conf.d
|
||||
- Download Recipes.conf to your conf.d folder
|
||||
- Open https://github.com/vabene1111/recipes/blob/develop/.env.template
|
||||
- Copy the text and save it as '.env' to your recipes folder (no filename extension!)
|
||||
- Add a POSTGRES_PASSWORD
|
||||
- Once done, it should look like this:
|
||||
### 2. Download templates
|
||||
!!!info
|
||||
vabene1111 gives you a few samples for various setups to work with. I chose to use the plain setup for now.
|
||||
|
||||
* Open https://github.com/vabene1111/recipes/tree/develop/docs/install/docker ([link](https://github.com/vabene1111/recipes/tree/develop/docs/install/docker))
|
||||
* Download docker-compose.yml to your recipes folder ([direct link to plain](https://github.com/TandoorRecipes/recipes/raw/develop/docs/install/docker/plain/docker-compose.yml))
|
||||
* Open https://github.com/vabene1111/recipes/tree/develop/nginx/conf.d ([link](https://github.com/vabene1111/recipes/tree/develop/nginx/conf.d))
|
||||
* Download Recipes.conf to your conf.d folder ([direct link](https://raw.githubusercontent.com/TandoorRecipes/recipes/develop/nginx/conf.d/Recipes.conf))
|
||||
* Open https://github.com/vabene1111/recipes/blob/develop/.env.template ([link](https://github.com/vabene1111/recipes/blob/develop/.env.template))
|
||||
* Copy the text and save it as ```.env``` to your recipes folder (no filename extension!)
|
||||
* Add a ```POSTGRES_PASSWORD```
|
||||
* Once done, it should look like this:
|
||||
|
||||

|
||||
|
||||
3. Edit docker-compose.yml
|
||||
- Open docker-compose.yml in a text editor
|
||||
- This file tells docker how to setup recipes. Docker will create three containers for recipes to work, recipes, nginx and postgresql. They are all required and need to store and share data through the folders you created before.
|
||||
- Edit line 26, this line specifies which external synology port will point to which internal docker port. Chose a free port to use and replace the first number with it. You will open recipes by browsing to http://your.synology.ip:chosen.port, e.g. http://192.168.1.1:2000
|
||||
- If you want to use port 2000 you would edit to 2000:80
|
||||
### 3. Edit docker-compose.yml
|
||||
* Open docker-compose.yml in a text editor
|
||||
* This file tells docker how to setup recipes. Docker will create three containers for recipes to work, recipes, nginx and postgresql. They are all required and need to store and share data through the folders you created before.
|
||||
* Edit line 26, this line specifies which external synology port will point to which internal docker port. Chose a free port to use and replace the first number with it. You will open recipes by browsing to http://your.synology.ip:chosen.port, e.g. http://192.168.1.1:2000
|
||||
* If you want to use port 2000 you would edit to 2000:80
|
||||
|
||||
4. SSH into your Synology
|
||||
### 4. SSH into your Synology
|
||||
- You need to access your Synology through SSH
|
||||
- execute following commands
|
||||
- Execute following commands
|
||||
- `ssh root@your.synology.ip` connect to your synology. root password is the same as admin password, sometimes root access is not possible for whatever reason, then replace root with admin
|
||||
- `cd /volume1/docker/recipes` access the folder where you store docker-compose.yml
|
||||
- `docker-compose up -d` this starts your containers according to your docker-compose.yml. if you logged in with admin you will have to use `sudo docker-compose up -d` instead, it will ask for the admin password again.
|
||||
@@ -57,10 +58,10 @@ Creating recipes_nginx_recipes_1 ... done
|
||||
Creating recipes_db_recipes_1 ... done
|
||||
Creating recipes_web_recipes_1 ... done
|
||||
```
|
||||
- Browse to 192.168.1.1:2000 or whatever your IP and port are
|
||||
- While the containers are starting and doing whatever they need to do, you might still get HTTP errors e.g. 500 or 502. Just be patient and try again in a moment
|
||||
* Browse to 192.168.1.1:2000 or whatever your IP and port are
|
||||
* While the containers are starting and doing whatever they need to do, you might still get HTTP errors e.g. 500 or 502. Just be patient and try again in a moment
|
||||
|
||||
5. Firewall
|
||||
### 5. Firewall
|
||||
You need to set up firewall rules in order for the recipes_web container to be able to connect to the recipes_db container.
|
||||
|
||||
- Control Panel -> Security -> Firewall -> Edit Rules -> Create
|
||||
@@ -71,8 +72,9 @@ You need to set up firewall rules in order for the recipes_web container to be a
|
||||
- Action: Allow
|
||||
- Save and make sure it's above the deny rules
|
||||
|
||||
6. Additional SSL Setup
|
||||
Easiest way is to do it via Reverse Proxy
|
||||
### 6. Additional SSL Setup
|
||||
Easiest way is to do it via Reverse Proxy.
|
||||
|
||||
- Control Panel -> Login Portal (renamed Since DSM 7, previously Application Portal) -> Advanced -> Reverse Proxy
|
||||
- Create
|
||||
- insert name
|
||||
|
||||
@@ -10,18 +10,25 @@ unraid forum where he gives additional information.
|
||||
|
||||
## Installation
|
||||
|
||||
Recipes for unRAID is avialble via Community Applications.
|
||||
You will first need to install Community Applications (CA) by following the directions here:
|
||||
https://forums.unraid.net/topic/38582-plug-in-community-applications/
|
||||
### Install Community Applications
|
||||
|
||||
After that, you can go to the "Apps" tab in unRAID and search for Recipes and locate the Recipes container and install it.
|
||||
Tandoor for unRAID is available via `Community Applications`.
|
||||
You will first need to install `Community Applications (CA)` by following the directions here:
|
||||
[Unraid forums](https://forums.unraid.net/topic/38582-plug-in-community-applications/)
|
||||
|
||||
### Locate and install Tandoor Recipes
|
||||
|
||||
After that, you can go to the "Apps" tab in unRAID and search for `Tandoor Recipes`, locate the correct container and install it.
|
||||

|
||||
|
||||
The default settings should by fine for most users, just be sure to enter a secret key that is randomly generated.
|
||||
Then choose apply.
|
||||
### Configure settings
|
||||
|
||||
The default settings should be fine for most users, just be sure to enter a secret key that is randomly generated.
|
||||
Then click `Apply`.
|
||||

|
||||
|
||||
After the container installs, click on the Recipes icon and click the WebUI button to launch the web user interface.
|
||||
### Access website
|
||||
|
||||
After the container is installed, click on the `Tandoor Recipes` icon and click the WebUI button to launch the web user interface.
|
||||
Set the container to auto-start if you wish.
|
||||

|
||||
|
||||
@@ -138,6 +138,7 @@ ENABLE_SIGNUP = bool(int(os.getenv('ENABLE_SIGNUP', False)))
|
||||
ENABLE_METRICS = bool(int(os.getenv('ENABLE_METRICS', False)))
|
||||
|
||||
ENABLE_PDF_EXPORT = bool(int(os.getenv('ENABLE_PDF_EXPORT', False)))
|
||||
EXPORT_FILE_CACHE_DURATION = int(os.getenv('EXPORT_FILE_CACHE_DURATION', 600))
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
@@ -186,6 +187,8 @@ if LDAP_AUTH:
|
||||
}
|
||||
AUTH_LDAP_ALWAYS_UPDATE_USER = bool(int(os.getenv('AUTH_LDAP_ALWAYS_UPDATE_USER', True)))
|
||||
AUTH_LDAP_CACHE_TIMEOUT = int(os.getenv('AUTH_LDAP_CACHE_TIMEOUT', 3600))
|
||||
if 'AUTH_LDAP_TLS_CACERTFILE' in os.environ:
|
||||
AUTH_LDAP_GLOBAL_OPTIONS = { ldap.OPT_X_TLS_CACERTFILE: os.getenv('AUTH_LDAP_TLS_CACERTFILE') }
|
||||
|
||||
AUTHENTICATION_BACKENDS += [
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
@@ -425,3 +428,4 @@ EMAIL_USE_TLS = bool(int(os.getenv('EMAIL_USE_TLS', False)))
|
||||
EMAIL_USE_SSL = bool(int(os.getenv('EMAIL_USE_SSL', False)))
|
||||
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
|
||||
ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv('ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tandoor Recipes] ') # allauth sender prefix
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ Django==3.2.11
|
||||
cryptography==36.0.1
|
||||
django-annoying==0.10.6
|
||||
django-autocomplete-light==3.8.2
|
||||
django-cleanup==5.2.0
|
||||
django-crispy-forms==1.13.0
|
||||
django-cleanup==6.0.0
|
||||
django-crispy-forms==1.14.0
|
||||
django-filter==21.1
|
||||
django-tables2==2.4.1
|
||||
djangorestframework==3.13.1
|
||||
@@ -13,10 +13,10 @@ bleach-allowlist==1.0.3
|
||||
gunicorn==20.1.0
|
||||
lxml==4.7.1
|
||||
Markdown==3.3.6
|
||||
Pillow==9.0.0
|
||||
Pillow==9.0.1
|
||||
psycopg2-binary==2.9.3
|
||||
python-dotenv==0.19.2
|
||||
requests==2.27.0
|
||||
requests==2.27.1
|
||||
simplejson==3.17.6
|
||||
six==1.16.0
|
||||
webdavclient3==3.14.6
|
||||
@@ -25,22 +25,22 @@ icalendar==4.0.9
|
||||
pyyaml==6.0
|
||||
uritemplate==4.1.1
|
||||
beautifulsoup4==4.10.0
|
||||
microdata==0.7.2
|
||||
microdata==0.8.0
|
||||
Jinja2==3.0.3
|
||||
django-webpack-loader==1.4.1
|
||||
django-js-reverse==0.9.1
|
||||
django-allauth==0.47.0
|
||||
recipe-scrapers==13.10.1
|
||||
recipe-scrapers==13.12.1
|
||||
django-scopes==1.2.0
|
||||
pytest==6.2.5
|
||||
pytest-django==4.5.2
|
||||
django-treebeard==4.5.1
|
||||
django-cors-headers==3.10.1
|
||||
django-cors-headers==3.11.0
|
||||
django-storages==1.12.3
|
||||
boto3==1.20.27
|
||||
boto3==1.20.47
|
||||
django-prometheus==2.2.0
|
||||
django-hCaptcha==0.1.0
|
||||
django-hCaptcha==0.2.0
|
||||
python-ldap==3.4.0
|
||||
django-auth-ldap==4.0.0
|
||||
pytest-factoryboy==2.1.0
|
||||
pyppeteer==0.2.6
|
||||
pyppeteer==1.0.2
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@riophae/vue-treeselect": "^0.4.0",
|
||||
"axios": "^0.24.0",
|
||||
"bootstrap-vue": "^2.21.2",
|
||||
"core-js": "^3.20.2",
|
||||
"core-js": "^3.20.3",
|
||||
"html2pdf.js": "^0.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.1",
|
||||
@@ -44,13 +44,13 @@
|
||||
"@vue/cli-plugin-eslint": "~4.5.15",
|
||||
"@vue/cli-plugin-pwa": "~4.5.13",
|
||||
"@vue/cli-plugin-typescript": "^4.5.15",
|
||||
"@vue/cli-service": "~4.5.13",
|
||||
"@vue/compiler-sfc": "^3.2.20",
|
||||
"@vue/cli-service": "~4.5.15",
|
||||
"@vue/compiler-sfc": "^3.2.29",
|
||||
"@vue/eslint-config-typescript": "^10.0.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^7.28.0",
|
||||
"eslint-plugin-vue": "^8.0.3",
|
||||
"typescript": "~4.5.2",
|
||||
"typescript": "~4.5.5",
|
||||
"vue-cli-plugin-i18n": "^2.1.1",
|
||||
"webpack-bundle-tracker": "1.4.0",
|
||||
"workbox-expiration": "^6.3.0",
|
||||
|
||||
145
vue/src/apps/ExportResponseView/ExportResponseView.vue
Normal file
145
vue/src/apps/ExportResponseView/ExportResponseView.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<br/>
|
||||
|
||||
<template v-if="export_info !== undefined">
|
||||
|
||||
<template v-if="export_info.running">
|
||||
<h5 style="text-align: center">{{ $t('Exporting') }}...</h5>
|
||||
|
||||
<b-progress :max="export_info.total_recipes">
|
||||
<b-progress-bar :value="export_info.exported_recipes" :label="`${export_info.exported_recipes}/${export_info.total_recipes}`"></b-progress-bar>
|
||||
</b-progress>
|
||||
|
||||
<loading-spinner :size="25"></loading-spinner>
|
||||
</template>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12" v-if="!export_info.running">
|
||||
<span>{{ $t('Export_finished') }}! </span> <a :href="`${resolveDjangoUrl('viewExport') }`">{{ $t('Return to export') }} </a><br><br>
|
||||
|
||||
{{ $t('If download did not start automatically: ') }}
|
||||
|
||||
<template v-if="export_info.expired">
|
||||
<a disabled><del>{{ $t('Download') }}</del></a> ({{ $t('Expired') }})
|
||||
</template>
|
||||
<a v-else :href="`/export-file/${export_id}/`" ref="downloadAnchor" >{{ $t('Download') }}</a>
|
||||
|
||||
<br>
|
||||
{{ $t('The link will remain active for') }}
|
||||
|
||||
<template v-if="export_info.cache_duration > 3600">
|
||||
{{ export_info.cache_duration/3600 }}{{ $t('hr') }}
|
||||
</template>
|
||||
<template v-else-if="export_info.cache_duration > 60">
|
||||
{{ export_info.cache_duration/60 }}{{ $t('min') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ export_info.cache_duration }}{{ $t('sec') }}
|
||||
</template>
|
||||
|
||||
|
||||
<br>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<label for="id_textarea">{{ $t('Information') }}</label>
|
||||
<textarea id="id_textarea" ref="output_text" class="form-control" style="height: 50vh"
|
||||
v-html="export_info.msg"
|
||||
disabled></textarea>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
<br/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
|
||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||
|
||||
import {ResolveUrlMixin, makeToast, ToastMixin} from "@/utils/utils";
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
|
||||
import {ApiApiFactory} from "@/utils/openapi/api.ts";
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: 'ExportResponseView',
|
||||
mixins: [
|
||||
ResolveUrlMixin,
|
||||
ToastMixin,
|
||||
],
|
||||
components: {
|
||||
LoadingSpinner
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
export_id: window.EXPORT_ID,
|
||||
export_info: undefined,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.refreshData()
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
|
||||
this.dynamicIntervalTimeout = 250 //initial refresh rate
|
||||
this.run = setTimeout(this.dynamicInterval.bind(this), this.dynamicIntervalTimeout)
|
||||
},
|
||||
methods: {
|
||||
dynamicInterval: function(){
|
||||
|
||||
//update frequently at start but slowdown as it takes longer
|
||||
this.dynamicIntervalTimeout = Math.round(this.dynamicIntervalTimeout*((1+Math.sqrt(5))/2))
|
||||
if(this.dynamicIntervalTimeout > 5000) this.dynamicIntervalTimeout = 5000
|
||||
clearInterval(this.run);
|
||||
this.run = setInterval(this.dynamicInterval.bind(this), this.dynamicIntervalTimeout);
|
||||
|
||||
if ((this.export_id !== null) && window.navigator.onLine && this.export_info.running) {
|
||||
this.refreshData()
|
||||
let el = this.$refs.output_text
|
||||
el.scrollTop = el.scrollHeight;
|
||||
|
||||
if(this.export_info.expired)
|
||||
makeToast(this.$t("Error"), this.$t("The download link is expired!"), "danger")
|
||||
}
|
||||
},
|
||||
|
||||
startDownload: function(){
|
||||
this.$refs['downloadAnchor'].click()
|
||||
},
|
||||
|
||||
refreshData: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.retrieveExportLog(this.export_id).then(result => {
|
||||
this.export_info = result.data
|
||||
this.export_info.expired = !this.export_info.possibly_not_expired
|
||||
|
||||
if(!this.export_info.running)
|
||||
this.$nextTick(()=>{ this.startDownload(); } )
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
|
||||
</style>
|
||||
18
vue/src/apps/ExportResponseView/main.js
Normal file
18
vue/src/apps/ExportResponseView/main.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import Vue from 'vue'
|
||||
import App from './ExportResponseView.vue'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
// TODO move this and other default stuff to centralized JS file (verify nothing breaks)
|
||||
let publicPath = localStorage.STATIC_URL + 'vue/'
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
publicPath = 'http://localhost:8080/'
|
||||
}
|
||||
export default __webpack_public_path__ = publicPath // eslint-disable-line
|
||||
|
||||
|
||||
new Vue({
|
||||
i18n,
|
||||
render: h => h(App),
|
||||
}).$mount('#app')
|
||||
174
vue/src/apps/ExportView/ExportView.vue
Normal file
174
vue/src/apps/ExportView/ExportView.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
|
||||
<h2>{{ $t('Export') }}</h2>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
|
||||
<br/>
|
||||
<!-- TODO get option dynamicaly -->
|
||||
<select class="form-control" v-model="recipe_app">
|
||||
<option value="DEFAULT">Default</option>
|
||||
<option value="SAFFRON">Saffron</option>
|
||||
<option value="RECIPESAGE">Recipe Sage</option>
|
||||
<option value="PDF">PDF (experimental)</option>
|
||||
</select>
|
||||
|
||||
<br/>
|
||||
<b-form-checkbox v-model="export_all" @change="disabled_multiselect=$event" name="check-button" switch style="margin-top: 1vh">
|
||||
{{ $t('All recipes') }}
|
||||
</b-form-checkbox>
|
||||
|
||||
<multiselect
|
||||
:searchable="true"
|
||||
:disabled="disabled_multiselect"
|
||||
v-model="recipe_list"
|
||||
:options="recipes"
|
||||
:close-on-select="false"
|
||||
:clear-on-select="true"
|
||||
:hide-selected="true"
|
||||
:preserve-search="true"
|
||||
placeholder="Select Recipes"
|
||||
:taggable="false"
|
||||
label="name"
|
||||
track-by="id"
|
||||
id="id_recipes"
|
||||
:multiple="true"
|
||||
:loading="recipes_loading"
|
||||
@search-change="searchRecipes">
|
||||
</multiselect>
|
||||
|
||||
<br/>
|
||||
<button @click="exportRecipe()" class="btn btn-primary shadow-none"><i class="fas fa-file-export"></i> {{ $t('Export') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
|
||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
|
||||
import {StandardToasts, makeToast, resolveDjangoUrl} from "@/utils/utils";
|
||||
import Multiselect from "vue-multiselect";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api.ts";
|
||||
import axios from "axios";
|
||||
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
|
||||
export default {
|
||||
name: 'ExportView',
|
||||
/*mixins: [
|
||||
ResolveUrlMixin,
|
||||
ToastMixin,
|
||||
],*/
|
||||
components: {Multiselect},
|
||||
data() {
|
||||
return {
|
||||
export_id: window.EXPORT_ID,
|
||||
loading: false,
|
||||
disabled_multiselect: false,
|
||||
|
||||
recipe_app: 'DEFAULT',
|
||||
recipe_list: [],
|
||||
recipes_loading: false,
|
||||
recipes: [],
|
||||
export_all: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if(this.export_id)
|
||||
this.insertRequested()
|
||||
else
|
||||
this.searchRecipes('')
|
||||
},
|
||||
methods: {
|
||||
|
||||
insertRequested: function(){
|
||||
|
||||
let apiFactory = new ApiApiFactory()
|
||||
|
||||
this.recipes_loading = true
|
||||
|
||||
apiFactory.retrieveRecipe(this.export_id).then((response) => {
|
||||
this.recipes_loading = false
|
||||
this.recipe_list.push(response.data)
|
||||
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||
}).then(e => this.searchRecipes(''))
|
||||
},
|
||||
|
||||
searchRecipes: function (query) {
|
||||
|
||||
let apiFactory = new ApiApiFactory()
|
||||
|
||||
this.recipes_loading = true
|
||||
|
||||
let maxResultLenght = 1000
|
||||
apiFactory.listRecipes(query, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, 1, maxResultLenght).then((response) => {
|
||||
this.recipes = response.data.results;
|
||||
this.recipes_loading = false
|
||||
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||
})
|
||||
},
|
||||
|
||||
exportRecipe: function () {
|
||||
|
||||
if (this.recipe_list.length < 1 && this.export_all == false) {
|
||||
makeToast(this.$t("Error"), this.$t("Select at least one recipe"), "danger")
|
||||
return;
|
||||
}
|
||||
|
||||
this.error = undefined
|
||||
this.loading = true
|
||||
let formData = new FormData();
|
||||
formData.append('type', this.recipe_app);
|
||||
formData.append('all', this.export_all)
|
||||
|
||||
for (var i = 0; i < this.recipe_list.length; i++) {
|
||||
formData.append('recipes', this.recipe_list[i].id);
|
||||
}
|
||||
|
||||
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
axios.post(resolveDjangoUrl('view_export',), formData).then((response) => {
|
||||
if (response.data['error'] !== undefined){
|
||||
makeToast(this.$t("Error"), response.data['error'],"warning")
|
||||
}else{
|
||||
window.location.href = resolveDjangoUrl('view_export_response', response.data['export_id'])
|
||||
}
|
||||
|
||||
}).catch((err) => {
|
||||
this.error = err.data
|
||||
this.loading = false
|
||||
console.log(err)
|
||||
makeToast(this.$t("Error"), this.$t("There was an error loading a resource!"), "warning")
|
||||
})
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||
|
||||
<style>
|
||||
|
||||
|
||||
</style>
|
||||
18
vue/src/apps/ExportView/main.js
Normal file
18
vue/src/apps/ExportView/main.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import Vue from 'vue'
|
||||
import App from './ExportView.vue'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
// TODO move this and other default stuff to centralized JS file (verify nothing breaks)
|
||||
let publicPath = localStorage.STATIC_URL + 'vue/'
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
publicPath = 'http://localhost:8080/'
|
||||
}
|
||||
export default __webpack_public_path__ = publicPath // eslint-disable-line
|
||||
|
||||
|
||||
new Vue({
|
||||
i18n,
|
||||
render: h => h(App),
|
||||
}).$mount('#app')
|
||||
@@ -54,20 +54,14 @@
|
||||
<div class="col-12 col-md-3 calender-options">
|
||||
<h5>{{ $t("Planner_Settings") }}</h5>
|
||||
<b-form>
|
||||
<b-form-group id="UomInput" :label="$t('Period')" :description="$t('Plan_Period_To_Show')"
|
||||
label-for="UomInput">
|
||||
<b-form-select id="UomInput" v-model="settings.displayPeriodUom"
|
||||
:options="options.displayPeriodUom"></b-form-select>
|
||||
<b-form-group id="UomInput" :label="$t('Period')" :description="$t('Plan_Period_To_Show')" label-for="UomInput">
|
||||
<b-form-select id="UomInput" v-model="settings.displayPeriodUom" :options="options.displayPeriodUom"></b-form-select>
|
||||
</b-form-group>
|
||||
<b-form-group id="PeriodInput" :label="$t('Periods')"
|
||||
:description="$t('Plan_Show_How_Many_Periods')" label-for="PeriodInput">
|
||||
<b-form-select id="PeriodInput" v-model="settings.displayPeriodCount"
|
||||
:options="options.displayPeriodCount"></b-form-select>
|
||||
<b-form-group id="PeriodInput" :label="$t('Periods')" :description="$t('Plan_Show_How_Many_Periods')" label-for="PeriodInput">
|
||||
<b-form-select id="PeriodInput" v-model="settings.displayPeriodCount" :options="options.displayPeriodCount"></b-form-select>
|
||||
</b-form-group>
|
||||
<b-form-group id="DaysInput" :label="$t('Starting_Day')" :description="$t('Starting_Day')"
|
||||
label-for="DaysInput">
|
||||
<b-form-select id="DaysInput" v-model="settings.startingDayOfWeek"
|
||||
:options="dayNames"></b-form-select>
|
||||
<b-form-group id="DaysInput" :label="$t('Starting_Day')" :description="$t('Starting_Day')" label-for="DaysInput">
|
||||
<b-form-select id="DaysInput" v-model="settings.startingDayOfWeek" :options="dayNames"></b-form-select>
|
||||
</b-form-group>
|
||||
<b-form-group id="WeekNumInput" :label="$t('Week_Numbers')">
|
||||
<b-form-checkbox v-model="settings.displayWeekNumbers" name="week_num">
|
||||
@@ -80,23 +74,18 @@
|
||||
<h5>{{ $t("Meal_Types") }}</h5>
|
||||
<div>
|
||||
<draggable :list="meal_types" group="meal_types" :empty-insert-threshold="10" @sort="sortMealTypes()" ghost-class="ghost">
|
||||
<b-card no-body class="mt-1 list-group-item p-2" style="cursor:move" v-for="(meal_type, index) in meal_types" v-hover
|
||||
:key="meal_type.id">
|
||||
<b-card no-body class="mt-1 list-group-item p-2" style="cursor: move" v-for="(meal_type, index) in meal_types" v-hover :key="meal_type.id">
|
||||
<b-card-header class="p-2 border-0">
|
||||
<div class="row">
|
||||
<div class="col-2">
|
||||
<button type="button" class="btn btn-lg shadow-none"><i
|
||||
class="fas fa-arrows-alt-v"></i></button>
|
||||
<button type="button" class="btn btn-lg shadow-none"><i class="fas fa-arrows-alt-v"></i></button>
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<h5 class="mt-1 mb-1">
|
||||
{{ meal_type.icon }} {{
|
||||
meal_type.name
|
||||
}}<span class="float-right text-primary" style="cursor:pointer"
|
||||
><i class="fa"
|
||||
v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }"
|
||||
@click="editOrSaveMealType(index)" aria-hidden="true"></i
|
||||
></span>
|
||||
{{ meal_type.icon }} {{ meal_type.name
|
||||
}}<span class="float-right text-primary" style="cursor: pointer"
|
||||
><i class="fa" v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }" @click="editOrSaveMealType(index)" aria-hidden="true"></i
|
||||
></span>
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,26 +93,19 @@
|
||||
<b-card-body class="p-4" v-if="meal_type.editing">
|
||||
<div class="form-group">
|
||||
<label>{{ $t("Name") }}</label>
|
||||
<input class="form-control" placeholder="Name" v-model="meal_type.name"/>
|
||||
<input class="form-control" placeholder="Name" v-model="meal_type.name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<emoji-input :field="'icon'" :label="$t('Icon')"
|
||||
:value="meal_type.icon"></emoji-input>
|
||||
<emoji-input :field="'icon'" :label="$t('Icon')" :value="meal_type.icon"></emoji-input>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ $t("Color") }}</label>
|
||||
<input class="form-control" type="color" name="Name"
|
||||
:value="meal_type.color"
|
||||
@change="meal_type.color = $event.target.value"/>
|
||||
<input class="form-control" type="color" name="Name" :value="meal_type.color" @change="meal_type.color = $event.target.value" />
|
||||
</div>
|
||||
<b-form-checkbox id="checkbox-1" v-model="meal_type.default"
|
||||
name="default_checkbox" class="mb-2">
|
||||
<b-form-checkbox id="checkbox-1" v-model="meal_type.default" name="default_checkbox" class="mb-2">
|
||||
{{ $t("Default") }}
|
||||
</b-form-checkbox>
|
||||
<button class="btn btn-danger" @click="deleteMealType(index)">{{
|
||||
$t("Delete")
|
||||
}}
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="deleteMealType(index)">{{ $t("Delete") }}</button>
|
||||
<button class="btn btn-primary float-right" @click="editOrSaveMealType(index)">
|
||||
{{ $t("Save") }}
|
||||
</button>
|
||||
@@ -147,15 +129,16 @@
|
||||
openEntryEdit(contextData.originalItem.entry)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pen"></i> {{
|
||||
$t("Edit")
|
||||
}}</a>
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pen"></i> {{ $t("Edit") }}</a>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="contextData.originalItem.entry.recipe != null"
|
||||
@click="$refs.menu.close();openRecipe(contextData.originalItem.entry.recipe)">
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pizza-slice"></i>
|
||||
{{ $t("Recipe") }}</a>
|
||||
v-if="contextData && contextData.originalItem && contextData.originalItem.entry.recipe != null"
|
||||
@click="
|
||||
$refs.menu.close()
|
||||
openRecipe(contextData.originalItem.entry.recipe)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pizza-slice"></i> {{ $t("Recipe") }}</a>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
@@ -163,8 +146,7 @@
|
||||
moveEntryLeft(contextData)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-left"></i>
|
||||
{{ $t("Move") }}</a>
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-left"></i> {{ $t("Move") }}</a>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
@@ -172,8 +154,7 @@
|
||||
moveEntryRight(contextData)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-right"></i>
|
||||
{{ $t("Move") }}</a>
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-right"></i> {{ $t("Move") }}</a>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
@@ -189,8 +170,7 @@
|
||||
addToShopping(contextData)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-shopping-cart"></i>
|
||||
{{ $t("Add_to_Shopping") }}</a>
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-shopping-cart"></i> {{ $t("Add_to_Shopping") }}</a>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
@@ -198,15 +178,12 @@
|
||||
deleteEntry(contextData)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2 text-danger" href="javascript:void(0)"><i class="fas fa-trash"></i>
|
||||
{{ $t("Delete") }}</a>
|
||||
<a class="dropdown-item p-2 text-danger" href="javascript:void(0)"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
<meal-plan-edit-modal
|
||||
:entry="entryEditing"
|
||||
:entryEditing_initial_recipe="entryEditing_initial_recipe"
|
||||
:entry-editing_initial_meal_type="entryEditing_initial_meal_type"
|
||||
:modal_title="modal_title"
|
||||
:edit_modal_show="edit_modal_show"
|
||||
@save-entry="editEntry"
|
||||
@@ -230,10 +207,11 @@
|
||||
<div class="col-12 mt-1" v-if="shopping_list.length > 0">
|
||||
<b-button-group>
|
||||
<b-button variant="success" @click="saveShoppingList"
|
||||
><i class="fas fa-external-link-alt"></i>
|
||||
><i class="fas fa-external-link-alt"></i>
|
||||
{{ $t("Open") }}
|
||||
</b-button>
|
||||
<b-button variant="danger" @click="shopping_list = []"><i class="fa fa-trash"></i>
|
||||
<b-button variant="danger" @click="shopping_list = []"
|
||||
><i class="fa fa-trash"></i>
|
||||
{{ $t("Clear") }}
|
||||
</b-button>
|
||||
</b-button-group>
|
||||
@@ -243,46 +221,37 @@
|
||||
</div>
|
||||
</template>
|
||||
<transition name="slide-fade">
|
||||
<div class="row fixed-bottom p-2 b-1 border-top text-center" style="background: rgba(255, 255, 255, 0.6)"
|
||||
v-if="current_tab === 0">
|
||||
<div class="row fixed-bottom p-2 b-1 border-top text-center" style="background: rgba(255, 255, 255, 0.6)" v-if="current_tab === 0">
|
||||
<div class="col-md-3 col-6">
|
||||
<button class="btn btn-block btn-success shadow-none" @click="createEntryClick(new Date())"><i
|
||||
class="fas fa-calendar-plus"></i> {{ $t("Create") }}
|
||||
</button>
|
||||
<button class="btn btn-block btn-success shadow-none" @click="createEntryClick(new Date())"><i class="fas fa-calendar-plus"></i> {{ $t("Create") }}</button>
|
||||
</div>
|
||||
<div class="col-md-3 col-6">
|
||||
<button class="btn btn-block btn-primary shadow-none" v-b-toggle.sidebar-shopping><i
|
||||
class="fas fa-shopping-cart"></i> {{ $t("Shopping_list") }}
|
||||
</button>
|
||||
<button class="btn btn-block btn-primary shadow-none" v-b-toggle.sidebar-shopping><i class="fas fa-shopping-cart"></i> {{ $t("Shopping_list") }}</button>
|
||||
</div>
|
||||
<div class="col-md-3 col-6">
|
||||
<a class="btn btn-block btn-primary shadow-none" :href="iCalUrl"
|
||||
><i class="fas fa-download"></i>
|
||||
><i class="fas fa-download"></i>
|
||||
{{ $t("Export_To_ICal") }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 col-6">
|
||||
<button class="btn btn-block btn-primary shadow-none disabled" v-b-tooltip.focus.top
|
||||
:title="$t('Coming_Soon')">
|
||||
<button class="btn btn-block btn-primary shadow-none disabled" v-b-tooltip.focus.top :title="$t('Coming_Soon')">
|
||||
{{ $t("Auto_Planner") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12 d-flex justify-content-center mt-2 d-block d-md-none">
|
||||
<b-button-toolbar key-nav aria-label="Toolbar with button groups">
|
||||
<b-button-group class="mx-1">
|
||||
<b-button v-html="'<<'"
|
||||
@click="setShowDate($refs.header.headerProps.previousPeriod)"></b-button>
|
||||
<b-button v-html="'<<'" @click="setShowDate($refs.header.headerProps.previousPeriod)"></b-button>
|
||||
<b-button v-html="'<'" @click="setStartingDay(-1)"></b-button>
|
||||
</b-button-group>
|
||||
<b-button-group class="mx-1">
|
||||
<b-button @click="setShowDate($refs.header.headerProps.currentPeriod)"><i
|
||||
class="fas fa-home"></i></b-button>
|
||||
<b-button @click="setShowDate($refs.header.headerProps.currentPeriod)"><i class="fas fa-home"></i></b-button>
|
||||
<b-form-datepicker button-only button-variant="secondary"></b-form-datepicker>
|
||||
</b-button-group>
|
||||
<b-button-group class="mx-1">
|
||||
<b-button v-html="'>'" @click="setStartingDay(1)"></b-button>
|
||||
<b-button v-html="'>>'"
|
||||
@click="setShowDate($refs.header.headerProps.nextPeriod)"></b-button>
|
||||
<b-button v-html="'>>'" @click="setShowDate($refs.header.headerProps.nextPeriod)"></b-button>
|
||||
</b-button-group>
|
||||
</b-button-toolbar>
|
||||
</div>
|
||||
@@ -293,7 +262,7 @@
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
import ContextMenu from "@/components/ContextMenu/ContextMenu"
|
||||
@@ -307,11 +276,11 @@ import moment from "moment"
|
||||
import draggable from "vuedraggable"
|
||||
import VueCookies from "vue-cookies"
|
||||
|
||||
import {ApiMixin, StandardToasts, ResolveUrlMixin} from "@/utils/utils"
|
||||
import {CalendarView, CalendarMathMixin} from "vue-simple-calendar/src/components/bundle"
|
||||
import {ApiApiFactory} from "@/utils/openapi/api"
|
||||
import { ApiMixin, StandardToasts, ResolveUrlMixin } from "@/utils/utils"
|
||||
import { CalendarView, CalendarMathMixin } from "vue-simple-calendar/src/components/bundle"
|
||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||
|
||||
const {makeToast} = require("@/utils/utils")
|
||||
const { makeToast } = require("@/utils/utils")
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
Vue.use(BootstrapVue)
|
||||
@@ -349,12 +318,12 @@ export default {
|
||||
current_context_menu_item: null,
|
||||
options: {
|
||||
displayPeriodUom: [
|
||||
{text: this.$t("Week"), value: "week"},
|
||||
{ text: this.$t("Week"), value: "week" },
|
||||
{
|
||||
text: this.$t("Month"),
|
||||
value: "month",
|
||||
},
|
||||
{text: this.$t("Year"), value: "year"},
|
||||
{ text: this.$t("Year"), value: "year" },
|
||||
],
|
||||
displayPeriodCount: [1, 2, 3],
|
||||
entryEditing: {
|
||||
@@ -385,20 +354,6 @@ export default {
|
||||
return this.$t("Edit_Meal_Plan_Entry")
|
||||
}
|
||||
},
|
||||
entryEditing_initial_recipe: function () {
|
||||
if (this.entryEditing.recipe != null) {
|
||||
return [this.entryEditing.recipe]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
},
|
||||
entryEditing_initial_meal_type: function () {
|
||||
if (this.entryEditing.meal_type != null) {
|
||||
return [this.entryEditing.meal_type]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
},
|
||||
plan_items: function () {
|
||||
let items = []
|
||||
this.plan_entries.forEach((entry) => {
|
||||
@@ -412,7 +367,7 @@ export default {
|
||||
dayNames: function () {
|
||||
let options = []
|
||||
this.getFormattedWeekdayNames(this.userLocale, "long", 0).forEach((day, index) => {
|
||||
options.push({text: day, value: index})
|
||||
options.push({ text: day, value: index })
|
||||
})
|
||||
return options
|
||||
},
|
||||
@@ -455,7 +410,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
openRecipe: function (recipe) {
|
||||
window.open(this.resolveDjangoUrl('view_recipe', recipe.id))
|
||||
window.open(this.resolveDjangoUrl("view_recipe", recipe.id))
|
||||
},
|
||||
addToShopping(entry) {
|
||||
if (entry.originalItem.entry.recipe !== null) {
|
||||
@@ -491,7 +446,7 @@ export default {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient
|
||||
.createMealType({name: this.$t("Meal_Type")})
|
||||
.createMealType({ name: this.$t("Meal_Type") })
|
||||
.then((e) => {
|
||||
this.periodChangedCallback(this.current_period)
|
||||
})
|
||||
@@ -879,7 +834,7 @@ having to override as much.
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background: #c8ebfb;
|
||||
opacity: 0.5;
|
||||
background: #c8ebfb;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<h3>
|
||||
<!-- <span><b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button></span> -->
|
||||
<model-menu />
|
||||
<span>{{ this.this_model.name }}</span>
|
||||
<span>{{ $t(this.this_model.name) }}</span>
|
||||
<span v-if="apiName !== 'Step'">
|
||||
<b-button variant="link" @click="startAction({ action: 'new' })">
|
||||
<i class="fas fa-plus-circle fa-2x"></i>
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
v-if="!ingredient.is_header"
|
||||
@click="ingredient.is_header = true">
|
||||
<i class="fas fa-heading fa-fw"></i>
|
||||
{{ $t("Make_header") }}
|
||||
{{ $t("Make_Header") }}
|
||||
</button>
|
||||
|
||||
<button type="button" class="dropdown-item"
|
||||
@@ -736,8 +736,8 @@ export default {
|
||||
}
|
||||
|
||||
this.recipe.servings = Math.floor(this.recipe.servings) // temporary fix until a proper framework for frontend input validation is established
|
||||
if (this.recipe.servings === "" || isNaN(this.recipe.servings)) {
|
||||
this.recipe.servings = 0
|
||||
if (this.recipe.servings === "" || isNaN(this.recipe.servings) || this.recipe.servings===0 ) {
|
||||
this.recipe.servings = 1
|
||||
}
|
||||
|
||||
apiFactory
|
||||
@@ -791,7 +791,7 @@ export default {
|
||||
let empty_step = {
|
||||
instruction: "",
|
||||
ingredients: [],
|
||||
show_as_header: true,
|
||||
show_as_header: false,
|
||||
time_visible: false,
|
||||
ingredients_visible: true,
|
||||
instruction_visible: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div id="app" style="margin-bottom: 4vh">
|
||||
<RecipeSwitcher ref="ref_recipe_switcher"/>
|
||||
<RecipeSwitcher ref="ref_recipe_switcher" />
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1">
|
||||
<div class="row">
|
||||
@@ -8,21 +8,15 @@
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-10 col-xl-8 mt-3 mb-3">
|
||||
<b-input-group>
|
||||
<b-input
|
||||
class="form-control form-control-lg form-control-borderless form-control-search"
|
||||
v-model="search.search_input" v-bind:placeholder="$t('Search')"></b-input>
|
||||
<b-input class="form-control form-control-lg form-control-borderless form-control-search" v-model="search.search_input" v-bind:placeholder="$t('Search')"></b-input>
|
||||
<b-input-group-append>
|
||||
<b-button v-b-tooltip.hover :title="$t('show_sql')" @click="showSQL()"
|
||||
v-if="debug && ui.sql_debug">
|
||||
<b-button v-b-tooltip.hover :title="$t('show_sql')" @click="showSQL()" v-if="debug && ui.sql_debug">
|
||||
<i class="fas fa-bug" style="font-size: 1.5em"></i>
|
||||
</b-button>
|
||||
<b-button variant="light" v-b-tooltip.hover :title="$t('Random Recipes')"
|
||||
@click="openRandom()">
|
||||
<b-button variant="light" v-b-tooltip.hover :title="$t('Random Recipes')" @click="openRandom()">
|
||||
<i class="fas fa-dice-five" style="font-size: 1.5em"></i>
|
||||
</b-button>
|
||||
<b-button v-b-toggle.collapse_advanced_search v-b-tooltip.hover
|
||||
:title="$t('Advanced Settings')"
|
||||
v-bind:variant="!searchFiltered(true) ? 'primary' : 'danger'">
|
||||
<b-button v-b-toggle.collapse_advanced_search v-b-tooltip.hover :title="$t('Advanced Settings')" v-bind:variant="!searchFiltered(true) ? 'primary' : 'danger'">
|
||||
<!-- TODO consider changing this icon to a filter -->
|
||||
<i class="fas fa-caret-down" v-if="!search.advanced_search_visible"></i>
|
||||
<i class="fas fa-caret-up" v-if="search.advanced_search_visible"></i>
|
||||
@@ -32,18 +26,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-collapse id="collapse_advanced_search" class="mt-2 shadow-sm"
|
||||
v-model="search.advanced_search_visible">
|
||||
<b-collapse id="collapse_advanced_search" class="mt-2 shadow-sm" v-model="search.advanced_search_visible">
|
||||
<div class="card">
|
||||
<div class="card-body p-4">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<a class="btn btn-primary btn-block text-uppercase"
|
||||
:href="resolveDjangoUrl('new_recipe')">{{ $t("New_Recipe") }}</a>
|
||||
<a class="btn btn-primary btn-block text-uppercase" :href="resolveDjangoUrl('new_recipe')">{{ $t("New_Recipe") }}</a>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<a class="btn btn-primary btn-block text-uppercase"
|
||||
:href="resolveDjangoUrl('data_import_url')">{{ $t("Import") }}</a>
|
||||
<a class="btn btn-primary btn-block text-uppercase" :href="resolveDjangoUrl('data_import_url')">{{ $t("Import") }}</a>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button
|
||||
@@ -62,92 +53,57 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<button id="id_settings_button"
|
||||
class="btn btn-primary btn-block text-uppercase"><i
|
||||
class="fas fa-cog fa-lg m-1"></i></button>
|
||||
<button id="id_settings_button" class="btn btn-primary btn-block text-uppercase"><i class="fas fa-cog fa-lg m-1"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-popover target="id_settings_button" triggers="click" placement="bottom">
|
||||
<b-tabs content-class="mt-1" small>
|
||||
<b-tab :title="$t('Settings')" active>
|
||||
<b-form-group v-bind:label="$t('Recently_Viewed')"
|
||||
label-for="popover-input-1" label-cols="6" class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.recently_viewed"
|
||||
id="popover-input-1" size="sm"></b-form-input>
|
||||
<b-form-group v-bind:label="$t('Recently_Viewed')" label-for="popover-input-1" label-cols="6" class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.recently_viewed" id="popover-input-1" size="sm"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group v-bind:label="$t('Recipes_per_page')"
|
||||
label-for="popover-input-page-count" label-cols="6"
|
||||
class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.page_size"
|
||||
id="popover-input-page-count"
|
||||
size="sm"></b-form-input>
|
||||
<b-form-group v-bind:label="$t('Recipes_per_page')" label-for="popover-input-page-count" label-cols="6" class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.page_size" id="popover-input-page-count" size="sm"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group v-bind:label="$t('Meal_Plan')" label-for="popover-input-2"
|
||||
label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.show_meal_plan"
|
||||
id="popover-input-2" size="sm"></b-form-checkbox>
|
||||
<b-form-group v-bind:label="$t('Meal_Plan')" label-for="popover-input-2" label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.show_meal_plan" id="popover-input-2" size="sm"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group v-if="ui.show_meal_plan"
|
||||
v-bind:label="$t('Meal_Plan_Days')"
|
||||
label-for="popover-input-5" label-cols="6" class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.meal_plan_days"
|
||||
id="popover-input-5" size="sm"></b-form-input>
|
||||
<b-form-group v-if="ui.show_meal_plan" v-bind:label="$t('Meal_Plan_Days')" label-for="popover-input-5" label-cols="6" class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.meal_plan_days" id="popover-input-5" size="sm"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group v-bind:label="$t('Sort_by_new')"
|
||||
label-for="popover-input-3" label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.sort_by_new"
|
||||
id="popover-input-3" size="sm"></b-form-checkbox>
|
||||
<b-form-group v-bind:label="$t('Sort_by_new')" label-for="popover-input-3" label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.sort_by_new" id="popover-input-3" size="sm"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
<div class="row" style="margin-top: 1vh">
|
||||
<div class="col-12">
|
||||
<a :href="resolveDjangoUrl('view_settings') + '#search'">{{
|
||||
$t("Search Settings")
|
||||
}}</a>
|
||||
<a :href="resolveDjangoUrl('view_settings') + '#search'">{{ $t("Search Settings") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</b-tab>
|
||||
<b-tab title="Expert Settings">
|
||||
<b-form-group v-bind:label="$t('remember_search')"
|
||||
label-for="popover-rem-search" label-cols="6"
|
||||
class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.remember_search"
|
||||
id="popover-rem-search"
|
||||
size="sm"></b-form-checkbox>
|
||||
<b-form-group v-bind:label="$t('remember_search')" label-for="popover-rem-search" label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.remember_search" id="popover-rem-search" size="sm"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
<b-form-group v-if="ui.remember_search"
|
||||
v-bind:label="$t('remember_hours')"
|
||||
label-for="popover-input-rem-hours" label-cols="6"
|
||||
class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.remember_hours"
|
||||
id="popover-rem-hours" size="sm"></b-form-input>
|
||||
<b-form-group v-if="ui.remember_search" v-bind:label="$t('remember_hours')" label-for="popover-input-rem-hours" label-cols="6" class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.remember_hours" id="popover-rem-hours" size="sm"></b-form-input>
|
||||
</b-form-group>
|
||||
<b-form-group v-bind:label="$t('tree_select')"
|
||||
label-for="popover-input-treeselect" label-cols="6"
|
||||
class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.tree_select"
|
||||
id="popover-input-treeselect"
|
||||
size="sm"></b-form-checkbox>
|
||||
<b-form-group v-bind:label="$t('tree_select')" label-for="popover-input-treeselect" label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.tree_select" id="popover-input-treeselect" size="sm"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
<b-form-group v-if="debug" v-bind:label="$t('sql_debug')"
|
||||
label-for="popover-input-sqldebug" label-cols="6"
|
||||
class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.sql_debug"
|
||||
id="popover-input-sqldebug"
|
||||
size="sm"></b-form-checkbox>
|
||||
<b-form-group v-if="debug" v-bind:label="$t('sql_debug')" label-for="popover-input-sqldebug" label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.sql_debug" id="popover-input-sqldebug" size="sm"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
|
||||
<div class="row" style="margin-top: 1vh">
|
||||
<div class="col-12" style="text-align: right">
|
||||
<b-button size="sm" variant="secondary" style="margin-right: 8px"
|
||||
@click="$root.$emit('bv::hide::popover')">{{ $t("Close") }}
|
||||
</b-button>
|
||||
<b-button size="sm" variant="secondary" style="margin-right: 8px" @click="$root.$emit('bv::hide::popover')">{{ $t("Close") }} </b-button>
|
||||
</div>
|
||||
</div>
|
||||
</b-popover>
|
||||
@@ -182,12 +138,8 @@
|
||||
></generic-multiselect>
|
||||
<b-input-group-append>
|
||||
<b-input-group-text>
|
||||
<b-form-checkbox v-model="search.search_keywords_or"
|
||||
name="check-button"
|
||||
@change="refreshData(false)"
|
||||
class="shadow-none" switch>
|
||||
<span class="text-uppercase"
|
||||
v-if="search.search_keywords_or">{{ $t("or") }}</span>
|
||||
<b-form-checkbox v-model="search.search_keywords_or" name="check-button" @change="refreshData(false)" class="shadow-none" switch>
|
||||
<span class="text-uppercase" v-if="search.search_keywords_or">{{ $t("or") }}</span>
|
||||
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
|
||||
</b-form-checkbox>
|
||||
</b-input-group-text>
|
||||
@@ -209,7 +161,7 @@
|
||||
:flat="true"
|
||||
:auto-load-root-options="false"
|
||||
searchNested
|
||||
:placeholder="$t('Ingredients')"
|
||||
:placeholder="$t('Foods')"
|
||||
:normalizer="normalizer"
|
||||
@input="refreshData(false)"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
@@ -226,13 +178,8 @@
|
||||
></generic-multiselect>
|
||||
<b-input-group-append>
|
||||
<b-input-group-text>
|
||||
<b-form-checkbox v-model="search.search_foods_or"
|
||||
name="check-button"
|
||||
@change="refreshData(false)"
|
||||
class="shadow-none" switch>
|
||||
<span class="text-uppercase" v-if="search.search_foods_or">{{
|
||||
$t("or")
|
||||
}}</span>
|
||||
<b-form-checkbox v-model="search.search_foods_or" name="check-button" @change="refreshData(false)" class="shadow-none" switch>
|
||||
<span class="text-uppercase" v-if="search.search_foods_or">{{ $t("or") }}</span>
|
||||
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
|
||||
</b-form-checkbox>
|
||||
</b-input-group-text>
|
||||
@@ -256,13 +203,8 @@
|
||||
></generic-multiselect>
|
||||
<b-input-group-append>
|
||||
<b-input-group-text>
|
||||
<b-form-checkbox v-model="search.search_books_or"
|
||||
name="check-button"
|
||||
@change="refreshData(false)"
|
||||
class="shadow-none" tyle="width: 100%" switch>
|
||||
<span class="text-uppercase" v-if="search.search_books_or">{{
|
||||
$t("or")
|
||||
}}</span>
|
||||
<b-form-checkbox v-model="search.search_books_or" name="check-button" @change="refreshData(false)" class="shadow-none" tyle="width: 100%" switch>
|
||||
<span class="text-uppercase" v-if="search.search_books_or">{{ $t("or") }}</span>
|
||||
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
|
||||
</b-form-checkbox>
|
||||
</b-input-group-text>
|
||||
@@ -299,9 +241,7 @@
|
||||
<div class="row">
|
||||
<div class="col col-md-12 text-right" style="margin-top: 2vh">
|
||||
<span class="text-muted">
|
||||
{{ $t("Page") }} {{ search.pagination_page }}/{{
|
||||
Math.ceil(pagination_count / ui.page_size)
|
||||
}}
|
||||
{{ $t("Page") }} {{ search.pagination_page }}/{{ Math.ceil(pagination_count / ui.page_size) }}
|
||||
<a href="#" @click="resetSearch"><i class="fas fa-times-circle"></i> {{ $t("Reset") }}</a>
|
||||
</span>
|
||||
</div>
|
||||
@@ -309,24 +249,18 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div
|
||||
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 0.8rem">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 0.8rem">
|
||||
<template v-if="!searchFiltered()">
|
||||
<recipe-card v-bind:key="`mp_${m.id}`" v-for="m in meal_plans" :recipe="m.recipe"
|
||||
:meal_plan="m" :footer_text="m.meal_type_name"
|
||||
footer_icon="far fa-calendar-alt"></recipe-card>
|
||||
<recipe-card v-bind:key="`mp_${m.id}`" v-for="m in meal_plans" :recipe="m.recipe" :meal_plan="m" :footer_text="m.meal_type_name" footer_icon="far fa-calendar-alt"></recipe-card>
|
||||
</template>
|
||||
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r"
|
||||
:footer_text="isRecentOrNew(r)[0]"
|
||||
:footer_icon="isRecentOrNew(r)[1]"></recipe-card>
|
||||
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r" :footer_text="isRecentOrNew(r)[0]" :footer_icon="isRecentOrNew(r)[1]"></recipe-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 2vh" v-if="!random_search">
|
||||
<div class="col col-md-12">
|
||||
<b-pagination pills v-model="search.pagination_page" :total-rows="pagination_count"
|
||||
:per-page="ui.page_size" @change="pageChange" align="center"></b-pagination>
|
||||
<b-pagination pills v-model="search.pagination_page" :total-rows="pagination_count" :per-page="ui.page_size" @change="pageChange" align="center"></b-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,26 +271,22 @@
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import VueCookies from "vue-cookies"
|
||||
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
import moment from "moment"
|
||||
import _debounce from "lodash/debounce"
|
||||
|
||||
import VueCookies from "vue-cookies"
|
||||
|
||||
Vue.use(VueCookies)
|
||||
|
||||
import {ApiMixin, ResolveUrlMixin} from "@/utils/utils"
|
||||
|
||||
import { ApiMixin, ResolveUrlMixin } from "@/utils/utils"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner" // TODO: is this deprecated?
|
||||
|
||||
import RecipeCard from "@/components/RecipeCard"
|
||||
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||
import {Treeselect, LOAD_CHILDREN_OPTIONS} from "@riophae/vue-treeselect" //TODO: delete
|
||||
import { Treeselect, LOAD_CHILDREN_OPTIONS } from "@riophae/vue-treeselect" //TODO: delete
|
||||
import "@riophae/vue-treeselect/dist/vue-treeselect.css" //TODO: delete
|
||||
import RecipeSwitcher from "@/components/Buttons/RecipeSwitcher"
|
||||
|
||||
Vue.use(VueCookies)
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
let SEARCH_COOKIE_NAME = "search_settings"
|
||||
@@ -365,7 +295,7 @@ let UI_COOKIE_NAME = "_uisearch_settings"
|
||||
export default {
|
||||
name: "RecipeSearchView",
|
||||
mixins: [ResolveUrlMixin, ApiMixin],
|
||||
components: {GenericMultiselect, RecipeCard, Treeselect, RecipeSwitcher},
|
||||
components: { GenericMultiselect, RecipeCard, Treeselect, RecipeSwitcher },
|
||||
data() {
|
||||
return {
|
||||
// this.Models and this.Actions inherited from ApiMixin
|
||||
@@ -416,12 +346,12 @@ export default {
|
||||
}
|
||||
}
|
||||
return [
|
||||
{id: 5, label: "⭐⭐⭐⭐⭐" + ratingCount(this.facets.Ratings?.["5.0"] ?? 0)},
|
||||
{id: 4, label: "⭐⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["4.0"] ?? 0)},
|
||||
{id: 3, label: "⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["3.0"] ?? 0)},
|
||||
{id: 2, label: "⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["2.0"] ?? 0)},
|
||||
{id: 1, label: "⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["1.0"] ?? 0)},
|
||||
{id: 0, label: this.$t("Unrated") + ratingCount(this.facets.Ratings?.["0.0"] ?? 0)},
|
||||
{ id: 5, label: "⭐⭐⭐⭐⭐" + ratingCount(this.facets.Ratings?.["5.0"] ?? 0) },
|
||||
{ id: 4, label: "⭐⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["4.0"] ?? 0) },
|
||||
{ id: 3, label: "⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["3.0"] ?? 0) },
|
||||
{ id: 2, label: "⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["2.0"] ?? 0) },
|
||||
{ id: 1, label: "⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["1.0"] ?? 0) },
|
||||
{ id: 0, label: this.$t("Unrated") + ratingCount(this.facets.Ratings?.["0.0"] ?? 0) },
|
||||
]
|
||||
},
|
||||
},
|
||||
@@ -435,42 +365,43 @@ export default {
|
||||
}
|
||||
let urlParams = new URLSearchParams(window.location.search)
|
||||
|
||||
|
||||
if (urlParams.has("keyword")) {
|
||||
this.search.search_keywords = []
|
||||
this.facets.Keywords = []
|
||||
for (let x of urlParams.getAll("keyword")) {
|
||||
let initial_keyword = {id: Number.parseInt(x), name: "loading..."}
|
||||
let initial_keyword = { id: Number.parseInt(x), name: "loading..." }
|
||||
this.search.search_keywords.push(initial_keyword)
|
||||
|
||||
this.genericAPI(this.Models.KEYWORD, this.Actions.FETCH, {id: initial_keyword.id}).then((response) => {
|
||||
let kw_index = this.search.search_keywords.findIndex((k => k.id === initial_keyword.id))
|
||||
this.$set(this.search.search_keywords, kw_index, response.data)
|
||||
this.$set(this.facets.Keywords, kw_index, response.data)
|
||||
}).catch((err) => {
|
||||
if (err.response.status === 404) {
|
||||
let kw_index = this.search.search_keywords.findIndex((k => k.id === initial_keyword.id))
|
||||
this.search.search_keywords.splice(kw_index, 1)
|
||||
this.facets.Keywords.splice(kw_index, 1)
|
||||
this.refreshData(false)
|
||||
}
|
||||
})
|
||||
this.genericAPI(this.Models.KEYWORD, this.Actions.FETCH, { id: initial_keyword.id })
|
||||
.then((response) => {
|
||||
let kw_index = this.search.search_keywords.findIndex((k) => k.id === initial_keyword.id)
|
||||
this.$set(this.search.search_keywords, kw_index, response.data)
|
||||
this.$set(this.facets.Keywords, kw_index, response.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response.status === 404) {
|
||||
let kw_index = this.search.search_keywords.findIndex((k) => k.id === initial_keyword.id)
|
||||
this.search.search_keywords.splice(kw_index, 1)
|
||||
this.facets.Keywords.splice(kw_index, 1)
|
||||
this.refreshData(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.facets.Foods = []
|
||||
for (let x of this.search.search_foods) {
|
||||
this.facets.Foods.push({id: x, name: "loading..."})
|
||||
this.facets.Foods.push({ id: x, name: "loading..." })
|
||||
}
|
||||
|
||||
this.facets.Keywords = []
|
||||
for (let x of this.search.search_keywords) {
|
||||
this.facets.Keywords.push({id: x, name: "loading..."})
|
||||
this.facets.Keywords.push({ id: x, name: "loading..." })
|
||||
}
|
||||
|
||||
this.facets.Books = []
|
||||
for (let x of this.search.search_books) {
|
||||
this.facets.Books.push({id: x, name: "loading..."})
|
||||
this.facets.Books.push({ id: x, name: "loading..." })
|
||||
}
|
||||
|
||||
this.loadMealPlan()
|
||||
@@ -526,7 +457,7 @@ export default {
|
||||
this.pagination_count = result.data.count
|
||||
|
||||
this.facets = result.data.facets
|
||||
this.recipes = this.removeDuplicates(result.data.results, (recipe) => recipe.id)
|
||||
this.recipes = [...this.removeDuplicates(result.data.results, (recipe) => recipe.id)]
|
||||
if (!this.searchFiltered()) {
|
||||
// if meal plans are being shown - filter out any meal plan recipes from the recipe list
|
||||
let mealPlans = []
|
||||
@@ -606,28 +537,27 @@ export default {
|
||||
if (!this.ui.tree_select) {
|
||||
return
|
||||
}
|
||||
let params = {hash: hash}
|
||||
let params = { hash: hash }
|
||||
if (facet) {
|
||||
params[facet] = id
|
||||
}
|
||||
return this.genericGetAPI("api_get_facets", params).then((response) => {
|
||||
this.facets = {...this.facets, ...response.data.facets}
|
||||
this.facets = { ...this.facets, ...response.data.facets }
|
||||
})
|
||||
},
|
||||
showSQL: function () {
|
||||
let params = this.buildParams()
|
||||
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {
|
||||
})
|
||||
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {})
|
||||
},
|
||||
// TODO refactor to combine with load KeywordChildren
|
||||
loadFoodChildren({action, parentNode, callback}) {
|
||||
loadFoodChildren({ action, parentNode, callback }) {
|
||||
if (action === LOAD_CHILDREN_OPTIONS) {
|
||||
if (this.facets?.cache_key) {
|
||||
this.getFacets(this.facets.cache_key, "food", parentNode.id).then(callback())
|
||||
}
|
||||
}
|
||||
},
|
||||
loadKeywordChildren({action, parentNode, callback}) {
|
||||
loadKeywordChildren({ action, parentNode, callback }) {
|
||||
if (action === LOAD_CHILDREN_OPTIONS) {
|
||||
if (this.facets?.cache_key) {
|
||||
this.getFacets(this.facets.cache_key, "keyword", parentNode.id).then(callback())
|
||||
@@ -657,7 +587,7 @@ export default {
|
||||
pageSize: this.ui.page_size,
|
||||
}
|
||||
if (!this.searchFiltered()) {
|
||||
params.options = {query: {last_viewed: this.ui.recently_viewed}}
|
||||
params.options = { query: { last_viewed: this.ui.recently_viewed } }
|
||||
}
|
||||
return params
|
||||
},
|
||||
|
||||
@@ -65,15 +65,7 @@
|
||||
<i class="fas fa-pizza-slice fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<input
|
||||
style="text-align: right; border-width: 0px; border: none; padding: 0px; padding-left: 0.5vw; padding-right: 8px; max-width: 80px"
|
||||
value="1"
|
||||
maxlength="3"
|
||||
min="0"
|
||||
type="number"
|
||||
class="form-control form-control-lg"
|
||||
v-model.number="servings"
|
||||
/>
|
||||
<CustomInputSpinButton v-model.number="servings" />
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<span class="text-primary">
|
||||
@@ -101,13 +93,14 @@
|
||||
:servings="servings"
|
||||
:header="true"
|
||||
@checked-state-changed="updateIngredientCheckedState"
|
||||
@change-servings="servings = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh" :alt="$t('Recipe_Image')" v-if="recipe.image !== null" />
|
||||
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh" :alt="$t('Recipe_Image')" v-if="recipe.image !== null" @load="onImgLoad" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -174,6 +167,7 @@ import StepComponent from "@/components/StepComponent"
|
||||
import KeywordsComponent from "@/components/KeywordsComponent"
|
||||
import NutritionComponent from "@/components/NutritionComponent"
|
||||
import RecipeSwitcher from "@/components/Buttons/RecipeSwitcher"
|
||||
import CustomInputSpinButton from "@/components/CustomInputSpinButton"
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
@@ -195,6 +189,7 @@ export default {
|
||||
LoadingSpinner,
|
||||
AddRecipeToBook,
|
||||
RecipeSwitcher,
|
||||
CustomInputSpinButton,
|
||||
},
|
||||
computed: {
|
||||
ingredient_factor: function () {
|
||||
@@ -246,6 +241,9 @@ export default {
|
||||
this.start_time = moment().format("yyyy-MM-DDTHH:mm")
|
||||
}
|
||||
|
||||
|
||||
if(recipe.image === null) this.printReady()
|
||||
|
||||
this.recipe = this.rootrecipe = recipe
|
||||
this.servings = this.servings_cache[this.rootrecipe.id] = recipe.servings
|
||||
this.loading = false
|
||||
@@ -272,13 +270,20 @@ export default {
|
||||
this.servings = this.servings_cache?.[e.id] ?? e.servings
|
||||
}
|
||||
},
|
||||
printReady: function(){
|
||||
const template = document.createElement("template");
|
||||
template.id = "printReady";
|
||||
document.body.appendChild(template);
|
||||
},
|
||||
onImgLoad: function(){
|
||||
this.printReady()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app > div > div{
|
||||
#app > div > div {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
<div class="collapse" :id="'section-' + sectionID(x, i)" visible role="tabpanel" :class="{ show: x == 'false' }">
|
||||
<!-- passing an array of values to the table grouped by Food -->
|
||||
<transition-group name="slide-fade">
|
||||
<div v-for="(entries, x) in Object.entries(s)" :key="x">
|
||||
<div class="mx-4" v-for="(entries, x) in Object.entries(s)" :key="x">
|
||||
<transition name="slide-fade" mode="out-in">
|
||||
<ShoppingLineItem
|
||||
:entries="entries[1]"
|
||||
@@ -190,6 +190,9 @@
|
||||
<td class="block-inline">
|
||||
<b-form-input min="1" type="number" :debounce="300" :value="r.recipe_mealplan.servings" @input="updateServings($event, r.list_recipe)"></b-form-input>
|
||||
</td>
|
||||
<td>
|
||||
<i class="btn text-primary far fa-eye fa-lg px-2 border-0" variant="link" :title="$t('view_recipe')" @click="editRecipeList($event, r)" />
|
||||
</td>
|
||||
<td>
|
||||
<i class="btn text-danger fas fa-trash fa-lg px-2 border-0" variant="link" :title="$t('Delete')" @click="deleteRecipe($event, r.list_recipe)" />
|
||||
</td>
|
||||
@@ -401,14 +404,14 @@
|
||||
</div>
|
||||
<div v-if="settings.mealplan_autoadd_shopping">
|
||||
<div class="row">
|
||||
<div class="col col-md-6">{{ $t("mealplan_autoadd_shopping") }}</div>
|
||||
<div class="col col-md-6">{{ $t("mealplan_autoexclude_onhand") }}</div>
|
||||
<div class="col col-md-6 text-right">
|
||||
<input type="checkbox" class="form-control settings-checkbox" v-model="settings.mealplan_autoexclude_onhand" @change="saveSettings" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row sm mb-3">
|
||||
<div class="col">
|
||||
<em class="small text-muted">{{ $t("mealplan_autoadd_shopping_desc") }}</em>
|
||||
<em class="small text-muted">{{ $t("mealplan_autoexclude_onhand_desc") }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -432,7 +435,10 @@
|
||||
<div class="col col-md-6 text-right">
|
||||
<generic-multiselect
|
||||
size="sm"
|
||||
@change="settings.shopping_share = $event.valsaveSettings()"
|
||||
@change="
|
||||
settings.shopping_share = $event.val
|
||||
saveSettings()
|
||||
"
|
||||
:model="Models.USER"
|
||||
:initial_selection="settings.shopping_share"
|
||||
label="username"
|
||||
@@ -557,6 +563,26 @@
|
||||
</div>
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
|
||||
<transition name="slided-fade">
|
||||
<div class="row fixed-bottom p-2 b-1 border-top text-center d-flex d-md-none" style="background: rgba(255, 255, 255, 0.6)" v-if="current_tab === 0">
|
||||
<div class="col-6">
|
||||
<a class="btn btn-block btn-success shadow-none" @click="entrymode = !entrymode"
|
||||
><i class="fas fa-cart-plus"></i>
|
||||
{{ $t("New Entry") }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<b-dropdown id="dropdown-dropup" block dropup variant="primary" class="shadow-none">
|
||||
<template #button-content> <i class="fas fa-download"></i> {{ $t("Export") }} </template>
|
||||
<DownloadPDF dom="#shoppinglist" name="shopping.pdf" :label="$t('download_pdf')" icon="far fa-file-pdf" />
|
||||
<DownloadCSV :items="csvData" :delim="settings.csv_delim" name="shopping.csv" :label="$t('download_csv')" icon="fas fa-file-csv" />
|
||||
<CopyToClipboard :items="csvData" :settings="settings" :label="$t('copy_to_clipboard')" icon="fas fa-clipboard-list" />
|
||||
<CopyToClipboard :items="csvData" :settings="settings" format="table" :label="$t('copy_markdown_table')" icon="fab fa-markdown" />
|
||||
</b-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<b-popover target="id_filters_button" triggers="click" placement="bottomleft" :title="$t('Filters')">
|
||||
<div>
|
||||
<b-form-group v-bind:label="$t('GroupBy')" label-for="popover-input-1" label-cols="6" class="mb-1">
|
||||
@@ -637,26 +663,7 @@
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
<transition name="slided-fade">
|
||||
<div class="row fixed-bottom p-2 b-1 border-top text-center d-flex d-md-none" style="background: rgba(255, 255, 255, 0.6)" v-if="current_tab === 0">
|
||||
<div class="col-6">
|
||||
<a class="btn btn-block btn-success shadow-none" @click="entrymode = !entrymode"
|
||||
><i class="fas fa-cart-plus"></i>
|
||||
{{ $t("New Entry") }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<b-dropdown id="dropdown-dropup" block dropup variant="primary" class="shadow-none">
|
||||
<template #button-content> <i class="fas fa-download"></i> {{ $t("Export") }} </template>
|
||||
<DownloadPDF dom="#shoppinglist" name="shopping.pdf" :label="$t('download_pdf')" icon="far fa-file-pdf" />
|
||||
<DownloadCSV :items="csvData" :delim="settings.csv_delim" name="shopping.csv" :label="$t('download_csv')" icon="fas fa-file-csv" />
|
||||
<CopyToClipboard :items="csvData" :settings="settings" :label="$t('copy_to_clipboard')" icon="fas fa-clipboard-list" />
|
||||
<CopyToClipboard :items="csvData" :settings="settings" format="table" :label="$t('copy_markdown_table')" icon="fab fa-markdown" />
|
||||
</b-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<shopping-modal v-if="new_recipe.id" :recipe="new_recipe" :servings="parseInt(add_recipe_servings)" :modal_id="new_recipe.id" @finish="finishShopping" />
|
||||
<shopping-modal v-if="new_recipe.id" :recipe="new_recipe" :servings="parseInt(add_recipe_servings)" :modal_id="new_recipe.id" @finish="finishShopping" :list_recipe="new_recipe.list_recipe" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -908,6 +915,7 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
console.log(screen.height)
|
||||
this.getShoppingList()
|
||||
this.getSupermarkets()
|
||||
this.getShoppingCategories()
|
||||
@@ -922,8 +930,8 @@ export default {
|
||||
this.$nextTick(function () {
|
||||
if (this.$cookies.isKey(SETTINGS_COOKIE_NAME)) {
|
||||
this.entry_mode_simple = this.$cookies.get(SETTINGS_COOKIE_NAME)
|
||||
this.selected_supermarket = localStorage.getItem("shopping_v2_selected_supermarket") || undefined
|
||||
}
|
||||
this.selected_supermarket = localStorage.getItem("shopping_v2_selected_supermarket") || undefined
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
@@ -1398,11 +1406,23 @@ export default {
|
||||
window.removeEventListener("offline", this.updateOnlineStatus)
|
||||
},
|
||||
addRecipeToShopping() {
|
||||
console.log(this.new_recipe)
|
||||
this.$bvModal.show(`shopping_${this.new_recipe.id}`)
|
||||
},
|
||||
finishShopping() {
|
||||
this.add_recipe_servings = 1
|
||||
this.new_recipe = { id: undefined }
|
||||
this.edit_recipe_list = undefined
|
||||
this.getShoppingList()
|
||||
},
|
||||
editRecipeList(e, r) {
|
||||
this.new_recipe = { id: r.recipe_mealplan.recipe, name: r.recipe_mealplan.recipe_name, servings: r.recipe_mealplan.servings, list_recipe: r.list_recipe }
|
||||
this.$nextTick(function () {
|
||||
this.$bvModal.show(`shopping_${this.new_recipe.id}`)
|
||||
})
|
||||
|
||||
// this.$bvModal.show(`shopping_${this.new_recipe.id}`)
|
||||
},
|
||||
},
|
||||
directives: {
|
||||
hover: {
|
||||
@@ -1464,14 +1484,25 @@ export default {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media screen and (max-width: 768px) {
|
||||
#shoppinglist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
height: 65vh;
|
||||
height: 6vh;
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
}
|
||||
@media screen and (min-height: 700px) and (max-width: 768px) {
|
||||
#shoppinglist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
height: 72vh;
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
58
vue/src/components/Badges/Help.vue
Normal file
58
vue/src/components/Badges/Help.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<span><i class="mx-1 far fa-question-circle text-muted" @click="this_help.show = !this_help.show" /></span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import VueCookies from "vue-cookies"
|
||||
Vue.use(VueCookies)
|
||||
let HELP_COOKIE_NAME = "help_settings"
|
||||
|
||||
export default {
|
||||
name: "HelpBadge",
|
||||
props: {
|
||||
component: { type: String, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
help: {},
|
||||
|
||||
default: {
|
||||
show: true,
|
||||
},
|
||||
this_help: undefined,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(function () {
|
||||
if (this.$cookies.isKey(HELP_COOKIE_NAME)) {
|
||||
this.help = Object.assign({}, this.help, this.$cookies.get(HELP_COOKIE_NAME))
|
||||
}
|
||||
this.this_help = Object.assign({}, this.default, this.help?.[this.component])
|
||||
})
|
||||
},
|
||||
watch: {
|
||||
help: {
|
||||
handler() {
|
||||
this.$cookies.set(HELP_COOKIE_NAME, this.help)
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
this_help: {
|
||||
handler() {
|
||||
this.help[this.component] = Object.assign({}, this.this_help)
|
||||
this.$cookies.set(HELP_COOKIE_NAME, this.help)
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
"this_help.show": function () {
|
||||
if (this.this_help.show) {
|
||||
this.$emit("show")
|
||||
} else {
|
||||
this.$emit("hide")
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {},
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<span>
|
||||
<span v-if="!item.ignore_shopping">
|
||||
<b-button class="btn text-decoration-none px-1 border-0" variant="link" :id="`shopping${item.id}`" @click="addShopping()">
|
||||
<i
|
||||
class="fas"
|
||||
|
||||
@@ -5,21 +5,21 @@
|
||||
<template #button-content>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</template>
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_food')"> <i class="fas fa-leaf fa-fw"></i> {{ Models["FOOD"].name }} </b-dropdown-item>
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_food')"> <i class="fas fa-leaf fa-fw"></i> {{ $t(Models["FOOD"].name) }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_keyword')"> <i class="fas fa-tags fa-fw"></i> {{ Models["KEYWORD"].name }} </b-dropdown-item>
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_keyword')"> <i class="fas fa-tags fa-fw"></i> {{ $t(Models["KEYWORD"].name) }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_unit')"> <i class="fas fa-balance-scale fa-fw"></i> {{ Models["UNIT"].name }} </b-dropdown-item>
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_unit')"> <i class="fas fa-balance-scale fa-fw"></i> {{ $t(Models["UNIT"].name) }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket')"> <i class="fas fa-store-alt fa-fw"></i> {{ Models["SUPERMARKET"].name }} </b-dropdown-item>
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket')"> <i class="fas fa-store-alt fa-fw"></i> {{ $t(Models["SUPERMARKET"].name) }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket_category')"> <i class="fas fa-cubes fa-fw"></i> {{ Models["SHOPPING_CATEGORY"].name }} </b-dropdown-item>
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket_category')"> <i class="fas fa-cubes fa-fw"></i> {{ $t(Models["SHOPPING_CATEGORY"].name) }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_automation')"> <i class="fas fa-robot fa-fw"></i> {{ Models["AUTOMATION"].name }} </b-dropdown-item>
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_automation')"> <i class="fas fa-robot fa-fw"></i> {{ $t(Models["AUTOMATION"].name) }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_user_file')"> <i class="fas fa-file fa-fw"></i> {{ Models["USERFILE"].name }} </b-dropdown-item>
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_user_file')"> <i class="fas fa-file fa-fw"></i> {{ $t(Models["USERFILE"].name) }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_step')"> <i class="fas fa-puzzle-piece fa-fw"></i>{{ Models["STEP"].name }} </b-dropdown-item>
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_step')"> <i class="fas fa-puzzle-piece fa-fw"></i>{{ $t(Models["STEP"].name) }} </b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
86
vue/src/components/CustomInputSpinButton.vue
Normal file
86
vue/src/components/CustomInputSpinButton.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
// code taken from https://github.com/bootstrap-vue/bootstrap-vue/issues/4977#issuecomment-740215609 and modified
|
||||
<template>
|
||||
<b-input-group>
|
||||
<b-input-group-prepend>
|
||||
<b-button variant="outline-primary" class="py-0 px-2" size="sm" @click="valueChange(value - 1)">
|
||||
<b-icon icon="dash" font-scale="1.6" />
|
||||
</b-button>
|
||||
</b-input-group-prepend>
|
||||
|
||||
<b-form-input
|
||||
style="text-align: right; border-width: 0px; border: none; padding: 0px; padding-left: 0.5vw; padding-right: 8px; width: 50px"
|
||||
variant="outline-primary"
|
||||
:size="size"
|
||||
:value="value"
|
||||
type="number"
|
||||
min="0"
|
||||
class="border-secondary text-center"
|
||||
number
|
||||
@update="valueChange"
|
||||
/>
|
||||
|
||||
<b-input-group-append>
|
||||
<b-button variant="outline-primary" class="py-0 px-2" size="sm" @click="valueChange(value + 1)">
|
||||
<b-icon icon="plus" font-scale="1.6" />
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { BIcon, BIconDash, BIconPlus } from 'bootstrap-vue'
|
||||
|
||||
export default {
|
||||
name: 'CustomInputSpinButton',
|
||||
|
||||
components: {
|
||||
BIcon,
|
||||
|
||||
/* eslint-disable vue/no-unused-components */
|
||||
BIconDash,
|
||||
BIconPlus
|
||||
},
|
||||
|
||||
props: {
|
||||
|
||||
size: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'md',
|
||||
validator: function (value) {
|
||||
return ['sm', 'md', 'lg'].includes(value)
|
||||
}
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
valueChange (newValue) {
|
||||
if (newValue <= 0) {
|
||||
this.$emit('input', 0)
|
||||
} else {
|
||||
this.$emit('input', newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Remove up and down arrows inside number input */
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
v-model="selected_objects"
|
||||
:options="objects"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:clear-on-select="multiple"
|
||||
:hide-selected="multiple"
|
||||
:preserve-search="true"
|
||||
:internal-search="false"
|
||||
@@ -35,7 +35,7 @@ export default {
|
||||
// this.Models and this.Actions inherited from ApiMixin
|
||||
loading: false,
|
||||
objects: [],
|
||||
selected_objects: [],
|
||||
selected_objects: undefined,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
@@ -48,7 +48,7 @@ export default {
|
||||
},
|
||||
label: { type: String, default: "name" },
|
||||
parent_variable: { type: String, default: undefined },
|
||||
limit: { type: Number, default: 10 },
|
||||
limit: { type: Number, default: 25 },
|
||||
sticky_options: {
|
||||
type: Array,
|
||||
default() {
|
||||
@@ -61,6 +61,10 @@ export default {
|
||||
return []
|
||||
},
|
||||
},
|
||||
initial_single_selection: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
multiple: { type: Boolean, default: true },
|
||||
allow_create: { type: Boolean, default: false },
|
||||
create_placeholder: { type: String, default: "You Forgot to Add a Tag Placeholder" },
|
||||
@@ -71,18 +75,37 @@ export default {
|
||||
// watch it
|
||||
this.selected_objects = newVal
|
||||
},
|
||||
initial_single_selection: function (newVal, oldVal) {
|
||||
// watch it
|
||||
this.selected_objects = newVal
|
||||
},
|
||||
clear: function (newVal, oldVal) {
|
||||
this.selected_objects = []
|
||||
if (this.multiple || !this.initial_single_selection) {
|
||||
this.selected_objects = []
|
||||
} else {
|
||||
this.selected_objects = undefined
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.search("")
|
||||
this.selected_objects = this.initial_selection
|
||||
if (this.multiple || !this.initial_single_selection) {
|
||||
this.selected_objects = this.initial_selection
|
||||
} else {
|
||||
this.selected_objects = this.initial_single_selection
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
lookupPlaceholder() {
|
||||
return this.placeholder || this.model.name || this.$t("Search")
|
||||
},
|
||||
nothingSelected() {
|
||||
if (this.multiple || !this.initial_single_selection) {
|
||||
return this.selected_objects.length === 0 && this.initial_selection.length === 0
|
||||
} else {
|
||||
return !this.selected_objects && !this.initial_single_selection
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
@@ -95,8 +118,9 @@ export default {
|
||||
}
|
||||
this.genericAPI(this.model, this.Actions.LIST, options).then((result) => {
|
||||
this.objects = this.sticky_options.concat(result.data?.results ?? result.data)
|
||||
if (this.selected_objects.length === 0 && this.initial_selection.length === 0 && this.objects.length > 0) {
|
||||
if (this.nothingSelected && this.objects.length > 0) {
|
||||
this.objects.forEach((item) => {
|
||||
// select default items when present in object
|
||||
if ("default" in item) {
|
||||
if (item.default) {
|
||||
if (this.multiple) {
|
||||
@@ -109,6 +133,7 @@ export default {
|
||||
}
|
||||
})
|
||||
}
|
||||
// this.removeMissingItems() # This removes items that are on another page of results
|
||||
})
|
||||
},
|
||||
selectionChanged: function () {
|
||||
@@ -121,6 +146,13 @@ export default {
|
||||
this.search("")
|
||||
}, 750)
|
||||
},
|
||||
// removeMissingItems: function () {
|
||||
// if (this.multiple) {
|
||||
// this.selected_objects = this.selected_objects.filter((x) => !this.objects.map((y) => y.id).includes(x))
|
||||
// } else {
|
||||
// this.selected_objects = this.objects.filter((x) => x.id === this.selected_objects.id)[0]
|
||||
// }
|
||||
// },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<td class="d-print-non" v-if="detailed && !add_shopping_mode" @click="done">
|
||||
<td class="d-print-non" v-if="detailed && !show_shopping" @click="done">
|
||||
<i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
|
||||
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
|
||||
</td>
|
||||
@@ -34,19 +34,20 @@
|
||||
</td>
|
||||
<td v-else-if="show_shopping" class="text-right text-nowrap">
|
||||
<b-button
|
||||
v-if="!ingredient.food.ignore_shopping"
|
||||
class="btn text-decoration-none fas fa-shopping-cart px-2 user-select-none"
|
||||
variant="link"
|
||||
v-b-popover.hover.click.blur.html.top="{ title: ShoppingPopover, variant: 'outline-dark' }"
|
||||
:class="{
|
||||
'text-success': shopping_status === true,
|
||||
'text-muted': shopping_status === false,
|
||||
'text-warning': shopping_status === null,
|
||||
'text-success': ingredient.shopping_status === true,
|
||||
'text-muted': ingredient.shopping_status === false,
|
||||
'text-warning': ingredient.shopping_status === null,
|
||||
}"
|
||||
/>
|
||||
<span class="px-2">
|
||||
<span v-if="!ingredient.food.ignore_shopping" class="px-2">
|
||||
<input type="checkbox" class="align-middle" v-model="shop" @change="changeShopping" />
|
||||
</span>
|
||||
<on-hand-badge :item="ingredient.food" />
|
||||
<on-hand-badge v-if="!ingredient.food.ignore_shopping" :item="ingredient.food" />
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
@@ -63,111 +64,49 @@ export default {
|
||||
ingredient: Object,
|
||||
ingredient_factor: { type: Number, default: 1 },
|
||||
detailed: { type: Boolean, default: true },
|
||||
recipe_list: { type: Number }, // ShoppingListRecipe ID, to filter ShoppingStatus
|
||||
show_shopping: { type: Boolean, default: false },
|
||||
add_shopping_mode: { type: Boolean, default: false },
|
||||
shopping_list: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
}, // list of unchecked ingredients in shopping list
|
||||
},
|
||||
mixins: [ResolveUrlMixin, ApiMixin],
|
||||
data() {
|
||||
return {
|
||||
checked: false,
|
||||
shopping_status: null, // in any shopping list: boolean + null=in shopping list, but not for this recipe
|
||||
shopping_items: [],
|
||||
shop: false, // in shopping list for this recipe: boolean
|
||||
dirty: undefined,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
ShoppingListAndFilter: {
|
||||
immediate: true,
|
||||
handler(newVal, oldVal) {
|
||||
// this whole sections is overly complicated
|
||||
// trying to infer status of shopping for THIS recipe and THIS ingredient
|
||||
// without know which recipe it is.
|
||||
// If refactored:
|
||||
// ## Needs to handle same recipe (multiple mealplans) being in shopping list multiple times
|
||||
// ## Needs to handle same recipe being added as ShoppingListRecipe AND ingredients added from recipe as one-off
|
||||
|
||||
let filtered_list = this.shopping_list
|
||||
// if a recipe list is provided, filter the shopping list
|
||||
if (this.recipe_list) {
|
||||
filtered_list = filtered_list.filter((x) => x.list_recipe == this.recipe_list)
|
||||
}
|
||||
// how many ShoppingListRecipes are there for this recipe?
|
||||
let count_shopping_recipes = [...new Set(filtered_list.map((x) => x.list_recipe))].length
|
||||
let count_shopping_ingredient = filtered_list.filter((x) => x.ingredient == this.ingredient.id).length
|
||||
|
||||
if (count_shopping_recipes >= 1) {
|
||||
// This recipe is in the shopping list
|
||||
this.shop = false // don't check any boxes until user selects a shopping list to edit
|
||||
if (count_shopping_ingredient >= 1) {
|
||||
this.shopping_status = true // ingredient is in the shopping list - probably (but not definitely, this ingredient)
|
||||
} else if (this.ingredient?.food?.shopping) {
|
||||
this.shopping_status = null // food is in the shopping list, just not for this ingredient/recipe
|
||||
} else {
|
||||
// food is not in any shopping list
|
||||
this.shopping_status = false
|
||||
}
|
||||
} else {
|
||||
// there are not recipes in the shopping list
|
||||
// set default value
|
||||
this.shop = !this.ingredient?.food?.food_onhand && !this.ingredient?.food?.recipe
|
||||
this.$emit("add-to-shopping", { item: this.ingredient, add: this.shop })
|
||||
// mark checked if the food is in the shopping list for this ingredient/recipe
|
||||
if (count_shopping_ingredient >= 1) {
|
||||
// ingredient is in this shopping list (not entirely sure how this could happen?)
|
||||
this.shopping_status = true
|
||||
} else if (count_shopping_ingredient == 0 && this.ingredient?.food?.shopping) {
|
||||
// food is in the shopping list, just not for this ingredient/recipe
|
||||
this.shopping_status = null
|
||||
} else {
|
||||
// the food is not in any shopping list
|
||||
this.shopping_status = false
|
||||
}
|
||||
}
|
||||
|
||||
if (this.add_shopping_mode) {
|
||||
// if we are in add shopping mode (e.g. recipe_shopping_modal) start with all checks marked
|
||||
// except if on_hand (could be if recipe too?)
|
||||
this.shop = !this.ingredient?.food?.food_onhand && !this.ingredient?.food?.recipe
|
||||
}
|
||||
},
|
||||
ingredient: {
|
||||
handler() {},
|
||||
deep: true,
|
||||
},
|
||||
"ingredient.shop": function (newVal) {
|
||||
this.shop = newVal
|
||||
},
|
||||
},
|
||||
mounted() {},
|
||||
mounted() {
|
||||
this.shop = this.ingredient?.shop
|
||||
},
|
||||
computed: {
|
||||
ShoppingListAndFilter() {
|
||||
// hack to watch the shopping list and the recipe list at the same time
|
||||
return this.shopping_list.map((x) => x.id).join(this.recipe_list)
|
||||
},
|
||||
ShoppingPopover() {
|
||||
if (this.shopping_status == false) {
|
||||
if (this.ingredient?.shopping_status == false) {
|
||||
return this.$t("NotInShopping", { food: this.ingredient.food.name })
|
||||
} else {
|
||||
let list = this.shopping_list.filter((x) => x.food.id == this.ingredient.food.id)
|
||||
let category = this.$t("Category") + ": " + this.ingredient?.food?.supermarket_category?.name ?? this.$t("Undefined")
|
||||
let category = this.$t("Category") + ": " + this.ingredient?.category ?? this.$t("Undefined")
|
||||
let popover = []
|
||||
|
||||
list.forEach((x) => {
|
||||
;(this.ingredient?.shopping_list ?? []).forEach((x) => {
|
||||
popover.push(
|
||||
[
|
||||
"<tr style='border-bottom: 1px solid #ccc'>",
|
||||
"<td style='padding: 3px;'><em>",
|
||||
x?.recipe_mealplan?.name ?? "",
|
||||
x?.mealplan ?? "",
|
||||
"</em></td>",
|
||||
"<td style='padding: 3px;'>",
|
||||
x?.amount ?? "",
|
||||
"</td>",
|
||||
"<td style='padding: 3px;'>",
|
||||
x?.unit?.name ?? "" + "</td>",
|
||||
x?.unit ?? "" + "</td>",
|
||||
"<td style='padding: 3px;'>",
|
||||
x?.food?.name ?? "",
|
||||
x?.food ?? "",
|
||||
"</td></tr>",
|
||||
].join("")
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row text-right" v-if="ShoppingRecipes.length > 1">
|
||||
<div class="row text-right" v-if="ShoppingRecipes.length > 1 && !add_shopping_mode">
|
||||
<div class="col col-md-6 offset-md-6 text-right">
|
||||
<b-form-select v-model="selected_shoppingrecipe" :options="ShoppingRecipes" size="sm"></b-form-select>
|
||||
</div>
|
||||
@@ -31,14 +31,11 @@
|
||||
</tr>
|
||||
<template v-for="i in s.ingredients">
|
||||
<ingredient-component
|
||||
:ingredient="i"
|
||||
:ingredient="prepareIngredient(i)"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:key="i.id"
|
||||
:show_shopping="show_shopping"
|
||||
:shopping_list="shopping_list"
|
||||
:add_shopping_mode="add_shopping_mode"
|
||||
:detailed="detailed"
|
||||
:recipe_list="selected_shoppingrecipe"
|
||||
@checked-state-changed="$emit('checked-state-changed', $event)"
|
||||
@add-to-shopping="addShopping($event)"
|
||||
/>
|
||||
@@ -59,6 +56,7 @@ import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
import IngredientComponent from "@/components/IngredientComponent"
|
||||
import { ApiMixin, StandardToasts } from "@/utils/utils"
|
||||
import ShoppingListViewVue from "../apps/ShoppingListView/ShoppingListView.vue"
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
@@ -79,6 +77,7 @@ export default {
|
||||
detailed: { type: Boolean, default: true },
|
||||
header: { type: Boolean, default: false },
|
||||
add_shopping_mode: { type: Boolean, default: false },
|
||||
recipe_list: { type: Number, default: undefined },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -107,13 +106,14 @@ export default {
|
||||
watch: {
|
||||
ShoppingRecipes: function (newVal, oldVal) {
|
||||
if (newVal.length === 0 || this.add_shopping_mode) {
|
||||
this.selected_shoppingrecipe = undefined
|
||||
this.selected_shoppingrecipe = this.recipe_list
|
||||
} else if (newVal.length === 1) {
|
||||
this.selected_shoppingrecipe = newVal[0].value
|
||||
}
|
||||
},
|
||||
selected_shoppingrecipe: function (newVal, oldVal) {
|
||||
this.update_shopping = this.shopping_list.filter((x) => x.list_recipe === newVal).map((x) => x.ingredient)
|
||||
this.$emit("change-servings", this.ShoppingRecipes.filter((x) => x.value === this.selected_shoppingrecipe)[0].servings)
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
@@ -132,6 +132,7 @@ export default {
|
||||
let ingredient_list = this.steps
|
||||
.map((x) => x.ingredients)
|
||||
.flat()
|
||||
.filter((x) => (x.food !== null && x.food !== undefined))
|
||||
.map((x) => x.food.id)
|
||||
|
||||
let params = {
|
||||
@@ -140,13 +141,31 @@ export default {
|
||||
}
|
||||
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params).then((result) => {
|
||||
this.shopping_list = result.data
|
||||
|
||||
if (this.add_shopping_mode) {
|
||||
if (this.recipe_list) {
|
||||
this.$emit(
|
||||
"starting-cart",
|
||||
this.shopping_list.filter((x) => x.list_recipe === this.recipe_list).map((x) => x.ingredient)
|
||||
)
|
||||
} else {
|
||||
this.$emit(
|
||||
"starting-cart",
|
||||
this.steps
|
||||
.map((x) => x.ingredients)
|
||||
.flat()
|
||||
.filter((x) => x?.food?.food_onhand == false && x?.food?.ignore_shopping == false)
|
||||
.map((x) => x.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
saveShopping: function (del_shopping = false) {
|
||||
let servings = this.servings
|
||||
if (del_shopping) {
|
||||
servings = 0
|
||||
servings = -1
|
||||
}
|
||||
let params = {
|
||||
id: this.recipe,
|
||||
@@ -155,7 +174,7 @@ export default {
|
||||
servings: servings,
|
||||
}
|
||||
this.genericAPI(this.Models.RECIPE, this.Actions.SHOPPING, params)
|
||||
.then(() => {
|
||||
.then((result) => {
|
||||
if (del_shopping) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
} else if (this.selected_shoppingrecipe) {
|
||||
@@ -164,13 +183,6 @@ export default {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
if (!this.add_shopping_mode) {
|
||||
return this.getShopping(false)
|
||||
} else {
|
||||
this.$emit("shopping-added")
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (del_shopping) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||
@@ -186,13 +198,51 @@ export default {
|
||||
// ALERT: this will all break if ingredients are re-used between recipes
|
||||
if (e.add) {
|
||||
this.update_shopping.push(e.item.id)
|
||||
this.shopping_list.push({
|
||||
id: Math.random(),
|
||||
amount: e.item.amount,
|
||||
ingredient: e.item.id,
|
||||
food: e.item.food,
|
||||
list_recipe: this.selected_shoppingrecipe,
|
||||
})
|
||||
} else {
|
||||
this.update_shopping = this.update_shopping.filter((x) => x !== e.item.id)
|
||||
this.update_shopping = [...this.update_shopping.filter((x) => x !== e.item.id)]
|
||||
this.shopping_list = [...this.shopping_list.filter((x) => !(x.ingredient === e.item.id && x.list_recipe === this.selected_shoppingrecipe))]
|
||||
}
|
||||
if (this.add_shopping_mode) {
|
||||
this.$emit("add-to-shopping", e)
|
||||
}
|
||||
},
|
||||
prepareIngredient: function (i) {
|
||||
let shopping = this.shopping_list.filter((x) => x.ingredient === i.id)
|
||||
let selected_list = this.shopping_list.filter((x) => x.list_recipe === this.selected_shoppingrecipe && x.ingredient === i.id)
|
||||
// checked = in the selected shopping list OR if in shoppping mode without a selected recipe, the default value true unless it is ignored or onhand
|
||||
let checked = selected_list.length > 0 || (this.add_shopping_mode && !this.selected_shoppingrecipe && !i?.food?.ignore_recipe && !i?.food?.food_onhand)
|
||||
|
||||
let shopping_status = false // not in shopping list
|
||||
if (shopping.length > 0) {
|
||||
if (selected_list.length > 0) {
|
||||
shopping_status = true // in shopping list for *this* recipe
|
||||
} else {
|
||||
shopping_status = null // in shopping list but not *this* recipe
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...i,
|
||||
shop: checked,
|
||||
shopping_status: shopping_status, // possible values: true, false, null
|
||||
category: i.food?.supermarket_category?.name,
|
||||
shopping_list: shopping.map((x) => {
|
||||
return {
|
||||
mealplan: x?.recipe_mealplan?.name,
|
||||
amount: x.amount,
|
||||
food: x.food?.name,
|
||||
unit: x.unit?.name,
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -134,7 +134,7 @@ export default {
|
||||
flex-flow: row nowrap;
|
||||
min-height: 1.5em;
|
||||
line-height: 1;
|
||||
font-size: 1.5em;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.period-span-1 {
|
||||
|
||||
@@ -71,9 +71,7 @@ export default {
|
||||
image_placeholder: window.IMAGE_PLACEHOLDER,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log(this.value)
|
||||
},
|
||||
mounted() {},
|
||||
computed: {
|
||||
entry: function () {
|
||||
return this.value.originalItem
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<b-form-group>
|
||||
<generic-multiselect
|
||||
@change="selectRecipe"
|
||||
:initial_selection="entryEditing_initial_recipe"
|
||||
:initial_single_selection="entryEditing.recipe"
|
||||
:label="'name'"
|
||||
:model="Models.RECIPE"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
@@ -45,7 +45,7 @@
|
||||
v-bind:placeholder="$t('Meal_Type')"
|
||||
:limit="10"
|
||||
:multiple="false"
|
||||
:initial_selection="entryEditing_initial_meal_type"
|
||||
:initial_single_selection="entryEditing.meal_type"
|
||||
:allow_create="true"
|
||||
:create_placeholder="$t('Create_New_Meal_Type')"
|
||||
@new="createMealType"
|
||||
@@ -76,12 +76,16 @@
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
|
||||
</b-form-group>
|
||||
<b-input-group v-if="!autoMealPlan">
|
||||
<b-form-checkbox id="AddToShopping" v-model="entryEditing.addshopping" />
|
||||
<b-form-checkbox id="AddToShopping" v-model="mealplan_settings.addshopping" />
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("AddToShopping") }}</small>
|
||||
</b-input-group>
|
||||
<b-input-group v-if="mealplan_settings.addshopping">
|
||||
<b-form-checkbox id="reviewShopping" v-model="mealplan_settings.reviewshopping" />
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("review_shopping") }}</small>
|
||||
</b-input-group>
|
||||
</div>
|
||||
<div class="col-lg-6 d-none d-lg-block d-xl-block">
|
||||
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null" :detailed="false"></recipe-card>
|
||||
<recipe-card v-if="entryEditing.recipe" :recipe="entryEditing.recipe" :detailed="false"></recipe-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3 mb-3">
|
||||
@@ -99,22 +103,22 @@
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import VueCookies from "vue-cookies"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||
import { ApiMixin, getUserPreference } from "@/utils/utils"
|
||||
|
||||
const { ApiApiFactory } = require("@/utils/openapi/api")
|
||||
|
||||
const { StandardToasts } = require("@/utils/utils")
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
Vue.use(VueCookies)
|
||||
let MEALPLAN_COOKIE_NAME = "mealplan_settings"
|
||||
|
||||
export default {
|
||||
name: "MealPlanEditModal",
|
||||
props: {
|
||||
entry: Object,
|
||||
entryEditing_initial_recipe: Array,
|
||||
entryEditing_initial_meal_type: Array,
|
||||
entryEditing_inital_servings: Number,
|
||||
modal_title: String,
|
||||
modal_id: {
|
||||
@@ -137,18 +141,36 @@ export default {
|
||||
missing_recipe: false,
|
||||
missing_meal_type: false,
|
||||
default_plan_share: [],
|
||||
mealplan_settings: {
|
||||
addshopping: false,
|
||||
reviewshopping: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
entry: {
|
||||
handler() {
|
||||
this.entryEditing = Object.assign({}, this.entry)
|
||||
|
||||
if (this.entryEditing_inital_servings) {
|
||||
this.entryEditing.servings = this.entryEditing_inital_servings
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
entryEditing: {
|
||||
handler(newVal) {},
|
||||
deep: true,
|
||||
},
|
||||
mealplan_settings: {
|
||||
handler(newVal) {
|
||||
this.$cookies.set(MEALPLAN_COOKIE_NAME, this.mealplan_settings)
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
entryEditing_inital_servings: function (newVal) {
|
||||
this.entryEditing.servings = newVal
|
||||
},
|
||||
},
|
||||
mounted: function () {},
|
||||
computed: {
|
||||
@@ -158,6 +180,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
showModal() {
|
||||
if (this.$cookies.isKey(MEALPLAN_COOKIE_NAME)) {
|
||||
this.mealplan_settings = Object.assign({}, this.mealplan_settings, this.$cookies.get(MEALPLAN_COOKIE_NAME))
|
||||
}
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.listUserPreferences().then((result) => {
|
||||
@@ -180,8 +205,10 @@ export default {
|
||||
cancel = true
|
||||
}
|
||||
if (!cancel) {
|
||||
console.log("saving", { ...this.mealplan_settings, ...this.entryEditing })
|
||||
this.$bvModal.hide(`edit-modal`)
|
||||
this.$emit("save-entry", this.entryEditing)
|
||||
this.$emit("save-entry", { ...this.mealplan_settings, ...this.entryEditing })
|
||||
console.log("after emit", { ...this.mealplan_settings, ...this.entryEditing }.addshopping)
|
||||
}
|
||||
},
|
||||
deleteEntry() {
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-form-checkbox v-model="new_value">{{label}}</b-form-checkbox>
|
||||
<b-form-checkbox v-model="new_value">{{ label }}</b-form-checkbox>
|
||||
<em v-if="help" class="small text-muted">{{ help }}</em>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'CheckboxInput',
|
||||
props: {
|
||||
field: {type: String, default: 'You Forgot To Set Field Name'},
|
||||
label: {type: String, default: 'Checkbox Field'},
|
||||
value: {type: Boolean, default: false},
|
||||
show_move: {type: Boolean, default: false},
|
||||
show_merge: {type: Boolean, default: false},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
new_value: undefined,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.new_value = this.value
|
||||
},
|
||||
watch: {
|
||||
'new_value': function () {
|
||||
this.$root.$emit('change', this.field, this.new_value)
|
||||
name: "CheckboxInput",
|
||||
props: {
|
||||
field: { type: String, default: "You Forgot To Set Field Name" },
|
||||
label: { type: String, default: "Checkbox Field" },
|
||||
value: { type: Boolean, default: false },
|
||||
show_move: { type: Boolean, default: false },
|
||||
show_merge: { type: Boolean, default: false },
|
||||
help: { type: String, default: undefined },
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
}
|
||||
data() {
|
||||
return {
|
||||
new_value: undefined,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.new_value = this.value
|
||||
},
|
||||
watch: {
|
||||
new_value: function () {
|
||||
this.$root.$emit("change", this.field, this.new_value)
|
||||
},
|
||||
},
|
||||
methods: {},
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -2,22 +2,26 @@
|
||||
<div>
|
||||
<b-modal :id="'modal_' + id" @hidden="cancelAction">
|
||||
<template v-slot:modal-title>
|
||||
<h4>{{ form.title }}</h4>
|
||||
<h4 class="d-inline">{{ form.title }}</h4>
|
||||
<help-badge v-if="form.show_help" @show="show_help = true" @hide="show_help = false" :component="`GenericModal${form.title}`" />
|
||||
</template>
|
||||
<div v-for="(f, i) in form.fields" v-bind:key="i">
|
||||
<p v-if="visibleCondition(f, 'instruction')">{{ f.label }}</p>
|
||||
<lookup-input v-if="visibleCondition(f, 'lookup')" :form="f" :model="listModel(f.list)" @change="storeValue" />
|
||||
<checkbox-input class="mb-3" v-if="visibleCondition(f, 'checkbox')" :label="f.label" :value="f.value" :field="f.field" />
|
||||
<text-input v-if="visibleCondition(f, 'text')" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" />
|
||||
<lookup-input v-if="visibleCondition(f, 'lookup')" :form="f" :model="listModel(f.list)" @change="storeValue" :help="showHelp && f.help" />
|
||||
<checkbox-input class="mb-3" v-if="visibleCondition(f, 'checkbox')" :label="f.label" :value="f.value" :field="f.field" :help="showHelp && f.help" />
|
||||
<text-input v-if="visibleCondition(f, 'text')" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" :help="showHelp && f.help" :subtitle="f.subtitle" />
|
||||
<choice-input v-if="visibleCondition(f, 'choice')" :label="f.label" :value="f.value" :field="f.field" :options="f.options" :placeholder="f.placeholder" />
|
||||
<emoji-input v-if="visibleCondition(f, 'emoji')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
|
||||
<file-input v-if="visibleCondition(f, 'file')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
|
||||
<small-text v-if="visibleCondition(f, 'smalltext')" :value="f.value" />
|
||||
</div>
|
||||
|
||||
<template v-slot:modal-footer>
|
||||
<b-button class="float-right mx-1" variant="secondary" v-on:click="cancelAction">{{ $t("Cancel") }}</b-button>
|
||||
<b-button class="float-right mx-1" variant="primary" v-on:click="doAction">{{ form.ok_label }}</b-button>
|
||||
<div class="row w-100 justify-content-end">
|
||||
<div class="col-auto">
|
||||
<b-button class="mx-1" variant="secondary" v-on:click="cancelAction">{{ $t("Cancel") }}</b-button>
|
||||
<b-button class="mx-1" variant="primary" v-on:click="doAction">{{ form.ok_label }}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</b-modal>
|
||||
</div>
|
||||
@@ -31,7 +35,7 @@ import { getForm, formFunctions } from "@/utils/utils"
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||
import { ApiMixin, StandardToasts, ToastMixin } from "@/utils/utils"
|
||||
import { ApiMixin, StandardToasts, ToastMixin, getUserPreference } from "@/utils/utils"
|
||||
import CheckboxInput from "@/components/Modals/CheckboxInput"
|
||||
import LookupInput from "@/components/Modals/LookupInput"
|
||||
import TextInput from "@/components/Modals/TextInput"
|
||||
@@ -39,10 +43,11 @@ import EmojiInput from "@/components/Modals/EmojiInput"
|
||||
import ChoiceInput from "@/components/Modals/ChoiceInput"
|
||||
import FileInput from "@/components/Modals/FileInput"
|
||||
import SmallText from "@/components/Modals/SmallText"
|
||||
import HelpBadge from "@/components/Badges/Help"
|
||||
|
||||
export default {
|
||||
name: "GenericModalForm",
|
||||
components: { FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput, SmallText },
|
||||
components: { FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput, SmallText, HelpBadge },
|
||||
mixins: [ApiMixin, ToastMixin],
|
||||
props: {
|
||||
model: { required: true, type: Object },
|
||||
@@ -73,6 +78,7 @@ export default {
|
||||
form: {},
|
||||
dirty: false,
|
||||
special_handling: false,
|
||||
show_help: true,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -83,11 +89,19 @@ export default {
|
||||
buttonLabel() {
|
||||
return this.buttons[this.action].label
|
||||
},
|
||||
showHelp() {
|
||||
if (this.show_help) {
|
||||
return true
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
show: function () {
|
||||
if (this.show) {
|
||||
this.form = getForm(this.model, this.action, this.item1, this.item2)
|
||||
|
||||
if (this.form?.form_function) {
|
||||
this.form = formFunctions[this.form.form_function](this.form)
|
||||
}
|
||||
@@ -256,15 +270,33 @@ export default {
|
||||
let type_match = field?.type == field_type
|
||||
let checks = true
|
||||
if (type_match && field?.condition) {
|
||||
if (field.condition?.condition === "exists") {
|
||||
if ((this.item1[field.condition.field] != undefined) === field.condition.value) {
|
||||
checks = true
|
||||
} else {
|
||||
checks = false
|
||||
}
|
||||
const value = this.item1[field?.condition?.field]
|
||||
const preference = getUserPreference(field?.condition?.field)
|
||||
console.log("condition", field?.condition?.condition)
|
||||
switch (field?.condition?.condition) {
|
||||
case "field_exists":
|
||||
if ((value != undefined) === field.condition.value) {
|
||||
checks = true
|
||||
} else {
|
||||
checks = false
|
||||
}
|
||||
break
|
||||
case "preference__array_exists":
|
||||
if (preference?.length > 0 === field.condition.value) {
|
||||
checks = true
|
||||
} else {
|
||||
checks = false
|
||||
}
|
||||
break
|
||||
case "preference_equals":
|
||||
if (preference === field.condition.value) {
|
||||
checks = true
|
||||
} else {
|
||||
checks = false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return type_match && checks
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
@new="addNew"
|
||||
>
|
||||
</generic-multiselect>
|
||||
<em v-if="help" class="small text-muted">{{ help }}</em>
|
||||
</b-form-group>
|
||||
</div>
|
||||
</template>
|
||||
@@ -47,6 +48,7 @@ export default {
|
||||
class_list: { type: String, default: "mb-3" },
|
||||
show_label: { type: Boolean, default: true },
|
||||
clear: { type: Number },
|
||||
help: { type: String, default: undefined },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-modal :id="`shopping_${this.modal_id}`" hide-footer @show="loadRecipe">
|
||||
<b-modal :id="`shopping_${this.modal_id}`" @show="loadRecipe">
|
||||
<template v-slot:modal-title
|
||||
><h4>{{ $t("Add_Servings_to_Shopping", { servings: recipe_servings }) }}</h4></template
|
||||
>
|
||||
@@ -16,10 +16,11 @@
|
||||
:recipe="recipe.id"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="recipe_servings"
|
||||
:show_shopping="true"
|
||||
:add_shopping_mode="true"
|
||||
:recipe_list="list_recipe"
|
||||
:header="false"
|
||||
@add-to-shopping="addShopping($event)"
|
||||
@starting-cart="add_shopping = $event"
|
||||
/>
|
||||
</b-collapse>
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
@@ -34,10 +35,11 @@
|
||||
:recipe="r.recipe.id"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="recipe_servings"
|
||||
:show_shopping="true"
|
||||
:add_shopping_mode="true"
|
||||
:recipe_list="list_recipe"
|
||||
:header="false"
|
||||
@add-to-shopping="addShopping($event)"
|
||||
@starting-cart="add_shopping = [...add_shopping, ...$event]"
|
||||
/>
|
||||
</b-collapse>
|
||||
</b-card>
|
||||
@@ -46,18 +48,20 @@
|
||||
</b-card>
|
||||
</div>
|
||||
|
||||
<b-input-group class="my-3">
|
||||
<b-input-group-prepend is-text>
|
||||
{{ $t("Servings") }}
|
||||
</b-input-group-prepend>
|
||||
<template #modal-footer="">
|
||||
<b-input-group class="mr-3">
|
||||
<b-input-group-prepend is-text>
|
||||
{{ $t("Servings") }}
|
||||
</b-input-group-prepend>
|
||||
|
||||
<b-form-spinbutton min="1" v-model="recipe_servings" inline style="height: 3em"></b-form-spinbutton>
|
||||
<b-form-spinbutton min="1" v-model="recipe_servings" inline style="height: 3em"></b-form-spinbutton>
|
||||
|
||||
<b-input-group-append>
|
||||
<b-button variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button>
|
||||
<b-button variant="success" @click="saveShopping">{{ $t("Save") }} </b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
<b-input-group-append>
|
||||
<b-button variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button>
|
||||
<b-button variant="success" @click="saveShopping">{{ $t("Save") }} </b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</template>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -80,6 +84,8 @@ export default {
|
||||
recipe: { required: true, type: Object },
|
||||
servings: { type: Number, default: undefined },
|
||||
modal_id: { required: true, type: Number },
|
||||
mealplan: { type: Number, default: undefined },
|
||||
list_recipe: { type: Number, default: undefined },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -120,14 +126,6 @@ export default {
|
||||
this.steps = result.data.steps
|
||||
// ALERT: this will all break if ingredients are re-used between recipes
|
||||
// ALERT: this also doesn't quite work right if the same recipe appears multiple time in the related recipes
|
||||
this.add_shopping = [
|
||||
...this.add_shopping,
|
||||
...this.steps
|
||||
.map((x) => x.ingredients)
|
||||
.flat()
|
||||
.filter((x) => !x?.food?.food_onhand)
|
||||
.map((x) => x.id),
|
||||
]
|
||||
if (!this.recipe_servings) {
|
||||
this.recipe_servings = result.data?.servings
|
||||
}
|
||||
@@ -154,18 +152,20 @@ export default {
|
||||
})
|
||||
return Promise.all(promises)
|
||||
})
|
||||
.then(() => {
|
||||
this.add_shopping = [
|
||||
...this.add_shopping,
|
||||
...this.related_recipes
|
||||
.map((x) => x.steps)
|
||||
.flat()
|
||||
.map((x) => x.ingredients)
|
||||
.flat()
|
||||
.filter((x) => !x.food.override_ignore)
|
||||
.map((x) => x.id),
|
||||
]
|
||||
})
|
||||
// .then(() => {
|
||||
// if (!this.list_recipe) {
|
||||
// this.add_shopping = [
|
||||
// ...this.add_shopping,
|
||||
// ...this.related_recipes
|
||||
// .map((x) => x.steps)
|
||||
// .flat()
|
||||
// .map((x) => x.ingredients)
|
||||
// .flat()
|
||||
// .filter((x) => !x.food.override_ignore)
|
||||
// .map((x) => x.id),
|
||||
// ]
|
||||
// }
|
||||
// })
|
||||
})
|
||||
},
|
||||
addShopping: function (e) {
|
||||
@@ -181,6 +181,8 @@ export default {
|
||||
id: this.recipe.id,
|
||||
ingredients: this.add_shopping,
|
||||
servings: this.recipe_servings,
|
||||
mealplan: this.mealplan,
|
||||
list_recipe: this.list_recipe,
|
||||
}
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<div>
|
||||
<b-form-group v-bind:label="label" class="mb-3">
|
||||
<b-form-input v-model="new_value" type="text" :placeholder="placeholder"></b-form-input>
|
||||
<em v-if="help" class="small text-muted">{{ help }}</em>
|
||||
<small v-if="subtitle" class="text-muted">{{ subtitle }}</small>
|
||||
</b-form-group>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,7 +16,8 @@ export default {
|
||||
label: { type: String, default: "Text Field" },
|
||||
value: { type: String, default: "" },
|
||||
placeholder: { type: String, default: "You Should Add Placeholder Text" },
|
||||
show_merge: { type: Boolean, default: false },
|
||||
help: { type: String, default: undefined },
|
||||
subtitle: { type: String, default: undefined },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -1,102 +1,81 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="dropdown d-print-none">
|
||||
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink"
|
||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</a>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuLink">
|
||||
<a class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)"><i
|
||||
class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }}</a>
|
||||
<a class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)"><i class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }}</a>
|
||||
|
||||
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)"
|
||||
v-if="!recipe.internal"><i class="fas fa-exchange-alt fa-fw"></i> {{ $t("convert_internal") }}</a>
|
||||
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)" v-if="!recipe.internal"><i class="fas fa-exchange-alt fa-fw"></i> {{ $t("convert_internal") }}</a>
|
||||
|
||||
<a href="javascript:void(0);">
|
||||
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)"><i
|
||||
class="fas fa-bookmark fa-fw"></i> {{ $t("Manage_Books") }}
|
||||
</button>
|
||||
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)"><i class="fas fa-bookmark fa-fw"></i> {{ $t("Manage_Books") }}</button>
|
||||
</a>
|
||||
|
||||
<a class="dropdown-item"
|
||||
:href="`${resolveDjangoUrl('view_shopping')}?r=[${recipe.id},${servings_value}]`"
|
||||
v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
|
||||
<a class="dropdown-item" :href="`${resolveDjangoUrl('view_shopping')}?r=[${recipe.id},${servings_value}]`" v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
|
||||
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t("Add_to_Shopping") }}
|
||||
</a>
|
||||
<a class="dropdown-item" v-if="recipe.internal" @click="addToShopping" href="#"> <i
|
||||
class="fas fa-shopping-cart fa-fw"></i> {{ $t("create_shopping_new") }} </a>
|
||||
<a class="dropdown-item" v-if="recipe.internal" @click="addToShopping" href="#"> <i class="fas fa-shopping-cart fa-fw"></i> {{ $t("create_shopping_new") }} </a>
|
||||
|
||||
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i
|
||||
class="fas fa-calendar fa-fw"></i> {{ $t("Add_to_Plan") }} </a>
|
||||
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i class="fas fa-calendar fa-fw"></i> {{ $t("Add_to_Plan") }} </a>
|
||||
|
||||
<a href="javascript:void(0);">
|
||||
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i
|
||||
class="fas fa-clipboard-list fa-fw"></i> {{ $t("Log_Cooking") }}
|
||||
</button>
|
||||
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i class="fas fa-clipboard-list fa-fw"></i> {{ $t("Log_Cooking") }}</button>
|
||||
</a>
|
||||
|
||||
<a href="javascript:void(0);">
|
||||
<button class="dropdown-item" onclick="window.print()"><i class="fas fa-print fa-fw"></i>
|
||||
<button class="dropdown-item" onclick="window.print()">
|
||||
<i class="fas fa-print fa-fw"></i>
|
||||
{{ $t("Print") }}
|
||||
</button>
|
||||
</a>
|
||||
|
||||
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank"
|
||||
rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t("Export") }}</a>
|
||||
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank" rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t("Export") }}</a>
|
||||
|
||||
<a href="javascript:void(0);">
|
||||
<button class="dropdown-item" @click="pinRecipe()"><i class="fas fa-thumbtack fa-fw"></i>
|
||||
<button class="dropdown-item" @click="pinRecipe()">
|
||||
<i class="fas fa-thumbtack fa-fw"></i>
|
||||
{{ $t("Pin") }}
|
||||
</button>
|
||||
</a>
|
||||
|
||||
<a href="javascript:void(0);">
|
||||
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i
|
||||
class="fas fa-share-alt fa-fw"></i> {{ $t("Share") }}
|
||||
</button>
|
||||
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i class="fas fa-share-alt fa-fw"></i> {{ $t("Share") }}</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<cook-log :recipe="recipe" :modal_id="modal_id"></cook-log>
|
||||
<add-recipe-to-book :recipe="recipe" :modal_id="modal_id"></add-recipe-to-book>
|
||||
<add-recipe-to-book :recipe="recipe" :modal_id="modal_id" :entryEditing_inital_servings="servings_value"></add-recipe-to-book>
|
||||
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id" :mealplan="undefined" />
|
||||
|
||||
<b-modal :id="`modal-share-link_${modal_id}`" v-bind:title="$t('Share')" hide-footer>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<label v-if="recipe_share_link !== undefined">{{ $t("Public share link") }}</label>
|
||||
<input ref="share_link_ref" class="form-control" v-model="recipe_share_link"/>
|
||||
<b-button class="mt-2 mb-3 d-none d-md-inline" variant="secondary"
|
||||
@click="$bvModal.hide(`modal-share-link_${modal_id}`)">{{ $t("Close") }}
|
||||
</b-button>
|
||||
<b-button class="mt-2 mb-3 ml-md-2" variant="primary" @click="copyShareLink()">{{
|
||||
$t("Copy")
|
||||
}}
|
||||
</b-button>
|
||||
<b-button class="mt-2 mb-3 ml-2 float-right" variant="success" @click="shareIntend()">{{
|
||||
$t("Share")
|
||||
}} <i class="fa fa-share-alt"></i></b-button>
|
||||
<input ref="share_link_ref" class="form-control" v-model="recipe_share_link" />
|
||||
<b-button class="mt-2 mb-3 d-none d-md-inline" variant="secondary" @click="$bvModal.hide(`modal-share-link_${modal_id}`)">{{ $t("Close") }} </b-button>
|
||||
<b-button class="mt-2 mb-3 ml-md-2" variant="primary" @click="copyShareLink()">{{ $t("Copy") }} </b-button>
|
||||
<b-button class="mt-2 mb-3 ml-2 float-right" variant="success" @click="shareIntend()">{{ $t("Share") }} <i class="fa fa-share-alt"></i></b-button>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
|
||||
<meal-plan-edit-modal
|
||||
:entry="entryEditing"
|
||||
:entryEditing_initial_recipe="[recipe]"
|
||||
:entryEditing_inital_servings="recipe.servings"
|
||||
:entry-editing_initial_meal_type="[]"
|
||||
:entryEditing_inital_servings="servings_value"
|
||||
@save-entry="saveMealPlan"
|
||||
:modal_id="`modal-meal-plan_${modal_id}`"
|
||||
:allow_delete="false"
|
||||
:modal_title="$t('Create_Meal_Plan_Entry')"
|
||||
></meal-plan-edit-modal>
|
||||
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils"
|
||||
import { makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts } from "@/utils/utils"
|
||||
import CookLog from "@/components/CookLog"
|
||||
import axios from "axios"
|
||||
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
|
||||
@@ -104,7 +83,7 @@ import MealPlanEditModal from "@/components/MealPlanEditModal"
|
||||
import ShoppingModal from "@/components/Modals/ShoppingModal"
|
||||
import moment from "moment"
|
||||
import Vue from "vue"
|
||||
import {ApiApiFactory} from "@/utils/openapi/api"
|
||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
@@ -137,6 +116,7 @@ export default {
|
||||
},
|
||||
},
|
||||
entryEditing: {},
|
||||
mealplan: undefined,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
@@ -149,20 +129,36 @@ export default {
|
||||
mounted() {
|
||||
this.servings_value = this.servings === -1 ? this.recipe.servings : this.servings
|
||||
},
|
||||
watch: {
|
||||
recipe: {
|
||||
handler() {},
|
||||
deep: true,
|
||||
},
|
||||
servings: function (newVal) {
|
||||
this.servings_value = parseInt(newVal)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
pinRecipe: function () {
|
||||
let pinnedRecipes = JSON.parse(localStorage.getItem('pinned_recipes')) || []
|
||||
pinnedRecipes.push({id: this.recipe.id, name: this.recipe.name})
|
||||
localStorage.setItem('pinned_recipes', JSON.stringify(pinnedRecipes))
|
||||
let pinnedRecipes = JSON.parse(localStorage.getItem("pinned_recipes")) || []
|
||||
pinnedRecipes.push({ id: this.recipe.id, name: this.recipe.name })
|
||||
localStorage.setItem("pinned_recipes", JSON.stringify(pinnedRecipes))
|
||||
},
|
||||
saveMealPlan: function (entry) {
|
||||
entry.date = moment(entry.date).format("YYYY-MM-DD")
|
||||
let reviewshopping = entry.addshopping && entry.reviewshopping
|
||||
entry.addshopping = entry.addshopping && !entry.reviewshopping
|
||||
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient
|
||||
.createMealPlan(entry)
|
||||
.then((result) => {
|
||||
this.$bvModal.hide(`modal-meal-plan_${this.modal_id}`)
|
||||
if (reviewshopping) {
|
||||
this.mealplan = result.data.id
|
||||
this.servings_value = result.data.servings
|
||||
this.addToShopping()
|
||||
}
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -173,7 +169,9 @@ export default {
|
||||
this.entryEditing = this.options.entryEditing
|
||||
this.entryEditing.recipe = this.recipe
|
||||
this.entryEditing.date = moment(new Date()).format("YYYY-MM-DD")
|
||||
this.$bvModal.show(`modal-meal-plan_${this.modal_id}`)
|
||||
this.$nextTick(function () {
|
||||
this.$bvModal.show(`modal-meal-plan_${this.modal_id}`)
|
||||
})
|
||||
},
|
||||
createShareLink: function () {
|
||||
axios
|
||||
|
||||
@@ -1,334 +1,311 @@
|
||||
<template>
|
||||
<div id="shopping_line_item">
|
||||
<b-row align-h="start">
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
|
||||
v-if="settings.left_handed">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
|
||||
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
|
||||
</div>
|
||||
</b-button>
|
||||
</b-col>
|
||||
<b-col cols="1" class="align-items-center d-flex">
|
||||
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
|
||||
@click.stop="$emit('open-context-menu', $event, entries)">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
:class="settings.left_handed ? 'dropdown-spacing' : ''"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret">
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
<b-col cols="8" md="9">
|
||||
<b-row class="d-flex h-100">
|
||||
<b-col cols="5" md="3" class="d-flex align-items-center" v-if="Object.entries(formatAmount).length == 1">
|
||||
<strong class="mr-1">{{ Object.entries(formatAmount)[0][1] }}</strong> {{
|
||||
Object.entries(formatAmount)[0][0]
|
||||
}}
|
||||
</b-col>
|
||||
<b-col cols="5" md="3" class="d-flex flex-column" v-if="Object.entries(formatAmount).length != 1">
|
||||
<div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }}  
|
||||
{{ x[0] }}
|
||||
</div>
|
||||
</b-col>
|
||||
|
||||
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
|
||||
{{ formatFood }}
|
||||
</b-col>
|
||||
<b-col cols="3" data-html2canvas-ignore="true"
|
||||
class="align-items-center d-none d-md-flex justify-content-end">
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="p-0 mr-0 mr-md-2 p-md-2 text-decoration-none"
|
||||
variant="link">
|
||||
<div class="text-nowrap"><i class="fa fa-chevron-right rotate"
|
||||
:class="showDetails ? 'rotated' : ''"></i> <span
|
||||
class="d-none d-md-inline-block">{{ $t('Details') }}</span>
|
||||
</div>
|
||||
</b-button>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none"
|
||||
v-if="!settings.left_handed">
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
|
||||
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
|
||||
</div>
|
||||
</b-button>
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-row align-h="center" class="d-none d-md-flex">
|
||||
<b-col cols="12">
|
||||
<div class="small text-muted text-truncate">{{ formatHint }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<!-- detail rows -->
|
||||
<div class="card no-body mb-1 pt-2 align-content-center shadow-sm" v-if="showDetails">
|
||||
<div v-for="(e, x) in entries" :key="e.id">
|
||||
<b-row class="small justify-content-around">
|
||||
<b-col cols="auto" md="4" class="overflow-hidden text-nowrap">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
class="btn btn-link btn-sm m-0 p-0 pl-2"
|
||||
style="text-overflow: ellipsis"
|
||||
@click.stop="openRecipeCard($event, e)"
|
||||
@mouseover="openRecipeCard($event, e)">
|
||||
{{ formatOneRecipe(e) }}
|
||||
</button>
|
||||
</b-col>
|
||||
<b-col cols="auto" md="4" class="text-muted">{{ formatOneMealPlan(e) }}</b-col>
|
||||
<b-col cols="auto" md="4" class="text-muted text-right overflow-hidden text-nowrap pr-4">
|
||||
{{ formatOneCreatedBy(e) }}
|
||||
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<div id="shopping_line_item">
|
||||
<b-row align-h="start">
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
|
||||
v-if="settings.left_handed">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
|
||||
:checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
<b-col cols="1" class="align-items-center d-flex">
|
||||
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
|
||||
@click.stop="$emit('open-context-menu', $event, e)">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
:class="settings.left_handed ? 'dropdown-spacing' : ''"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret">
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
<b-col cols="8" md="9">
|
||||
<b-row class="d-flex align-items-center h-100">
|
||||
<b-col cols="5" md="3" class="d-flex align-items-center">
|
||||
<strong class="mr-1">{{ formatOneAmount(e) }}</strong> {{ formatOneUnit(e) }}
|
||||
</b-col>
|
||||
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
|
||||
{{ formatOneFood(e) }}
|
||||
</b-col>
|
||||
<b-col cols="12" class="d-flex d-md-none">
|
||||
<div class="small text-muted text-truncate" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none"
|
||||
v-if="!settings.left_handed">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
|
||||
:checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0" v-if="settings.left_handed">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
|
||||
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i></div>
|
||||
</b-button>
|
||||
</b-col>
|
||||
<b-col cols="1" class="align-items-center d-flex">
|
||||
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true" @click.stop="$emit('open-context-menu', $event, entries)">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
:class="settings.left_handed ? 'dropdown-spacing' : ''"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
|
||||
>
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="1" class="px-1 justify-content-center align-items-center d-none d-md-flex">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
|
||||
</b-col>
|
||||
<b-col cols="8" md="9">
|
||||
<b-row class="d-flex h-100">
|
||||
<b-col cols="5" md="3" class="d-flex align-items-center" v-if="Object.entries(formatAmount).length == 1">
|
||||
<strong class="mr-1">{{ Object.entries(formatAmount)[0][1] }}</strong> {{ Object.entries(formatAmount)[0][0] }}
|
||||
</b-col>
|
||||
<b-col cols="5" md="3" class="d-flex flex-column" v-if="Object.entries(formatAmount).length != 1">
|
||||
<div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">
|
||||
{{ x[1] }}  
|
||||
{{ x[0] }}
|
||||
</div>
|
||||
</b-col>
|
||||
|
||||
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
|
||||
{{ formatFood }}
|
||||
</b-col>
|
||||
<b-col cols="3" data-html2canvas-ignore="true" class="align-items-center d-none d-md-flex justify-content-end">
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="p-0 mr-0 mr-md-2 p-md-2 text-decoration-none" variant="link">
|
||||
<div class="text-nowrap">
|
||||
<i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i> <span class="d-none d-md-inline-block">{{ $t("Details") }}</span>
|
||||
</div>
|
||||
</b-button>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none" v-if="!settings.left_handed">
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
|
||||
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i></div>
|
||||
</b-button>
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
|
||||
</b-col>
|
||||
</b-row>
|
||||
<hr class="w-75" v-if="x !== entries.length -1"/>
|
||||
<div class="pb-4" v-if="x === entries.length -1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="m-1" v-if="!showDetails"/>
|
||||
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width: 300">
|
||||
<template #menu="{ contextData }" v-if="recipe">
|
||||
<ContextMenuItem>
|
||||
<RecipeCard :recipe="contextData" :detail="false"></RecipeCard>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="$refs.menu.close()">
|
||||
<b-form-group label-cols="9" content-cols="3" class="text-nowrap m-0 mr-2">
|
||||
<template #label>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-pizza-slice"></i> {{ $t("Servings") }}</a>
|
||||
</template>
|
||||
<div @click.prevent.stop>
|
||||
<b-form-input class="mt-2" min="0" type="number" v-model="servings"></b-form-input>
|
||||
<b-row align-h="center" class="d-none d-md-flex">
|
||||
<b-col cols="12">
|
||||
<div class="small text-muted text-truncate">{{ formatHint }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<!-- detail rows -->
|
||||
<div class="card no-body mb-1 pt-2 align-content-center shadow-sm" v-if="showDetails">
|
||||
<div v-for="(e, x) in entries" :key="e.id">
|
||||
<b-row class="small justify-content-around">
|
||||
<b-col cols="auto" md="4" class="overflow-hidden text-nowrap">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
class="btn btn-link btn-sm m-0 p-0 pl-2"
|
||||
style="text-overflow: ellipsis"
|
||||
@click.stop="openRecipeCard($event, e)"
|
||||
@mouseover="openRecipeCard($event, e)"
|
||||
>
|
||||
{{ formatOneRecipe(e) }}
|
||||
</button>
|
||||
</b-col>
|
||||
<b-col cols="auto" md="4" class="text-muted">{{ formatOneMealPlan(e) }}</b-col>
|
||||
<b-col cols="auto" md="4" class="text-muted text-right overflow-hidden text-nowrap pr-4">
|
||||
{{ formatOneCreatedBy(e) }}
|
||||
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-row align-h="start">
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0" v-if="settings.left_handed">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
|
||||
</b-col>
|
||||
<b-col cols="1" class="align-items-center d-flex">
|
||||
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true" @click.stop="$emit('open-context-menu', $event, e)">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
:class="settings.left_handed ? 'dropdown-spacing' : ''"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
|
||||
>
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
|
||||
</b-col>
|
||||
<b-col cols="8" md="9">
|
||||
<b-row class="d-flex align-items-center h-100">
|
||||
<b-col cols="5" md="3" class="d-flex align-items-center">
|
||||
<strong class="mr-1">{{ formatOneAmount(e) }}</strong> {{ formatOneUnit(e) }}
|
||||
</b-col>
|
||||
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
|
||||
{{ formatOneFood(e) }}
|
||||
</b-col>
|
||||
<b-col cols="12" class="d-flex d-md-none">
|
||||
<div class="small text-muted text-truncate" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none" v-if="!settings.left_handed">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
|
||||
</b-col>
|
||||
</b-row>
|
||||
<hr class="w-75" v-if="x !== entries.length - 1" />
|
||||
<div class="pb-4" v-if="x === entries.length - 1"></div>
|
||||
</div>
|
||||
</b-form-group>
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="m-1" v-if="!showDetails" />
|
||||
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width: 300">
|
||||
<template #menu="{ contextData }" v-if="recipe">
|
||||
<ContextMenuItem>
|
||||
<RecipeCard :recipe="contextData" :detail="false"></RecipeCard>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="$refs.menu.close()">
|
||||
<b-form-group label-cols="9" content-cols="3" class="text-nowrap m-0 mr-2">
|
||||
<template #label>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-pizza-slice"></i> {{ $t("Servings") }}</a>
|
||||
</template>
|
||||
<div @click.prevent.stop>
|
||||
<b-form-input class="mt-2" min="0" type="number" v-model="servings"></b-form-input>
|
||||
</div>
|
||||
</b-form-group>
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
import ContextMenu from "@/components/ContextMenu/ContextMenu"
|
||||
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
|
||||
import {ApiMixin} from "@/utils/utils"
|
||||
import { ApiMixin } from "@/utils/utils"
|
||||
import RecipeCard from "./RecipeCard.vue"
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
|
||||
// or i'm capturing it incorrectly
|
||||
name: "ShoppingLineItem",
|
||||
mixins: [ApiMixin],
|
||||
components: {RecipeCard, ContextMenu, ContextMenuItem},
|
||||
props: {
|
||||
entries: {
|
||||
type: Array,
|
||||
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
|
||||
// or i'm capturing it incorrectly
|
||||
name: "ShoppingLineItem",
|
||||
mixins: [ApiMixin],
|
||||
components: { RecipeCard, ContextMenu, ContextMenuItem },
|
||||
props: {
|
||||
entries: {
|
||||
type: Array,
|
||||
},
|
||||
settings: Object,
|
||||
groupby: { type: String },
|
||||
},
|
||||
settings: Object,
|
||||
groupby: {type: String},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showDetails: false,
|
||||
recipe: undefined,
|
||||
servings: 1,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
formatAmount: function () {
|
||||
let amount = {}
|
||||
this.entries.forEach((entry) => {
|
||||
let unit = entry?.unit?.name ?? "----"
|
||||
if (entry.amount) {
|
||||
if (amount[unit]) {
|
||||
amount[unit] += entry.amount
|
||||
} else {
|
||||
amount[unit] = entry.amount
|
||||
}
|
||||
data() {
|
||||
return {
|
||||
showDetails: false,
|
||||
recipe: undefined,
|
||||
servings: 1,
|
||||
}
|
||||
})
|
||||
for (const [k, v] of Object.entries(amount)) {
|
||||
amount[k] = Math.round(v * 100 + Number.EPSILON) / 100 // javascript hack to force rounding at 2 places
|
||||
}
|
||||
return amount
|
||||
},
|
||||
formatCategory: function () {
|
||||
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
|
||||
},
|
||||
formatChecked: function () {
|
||||
return this.entries.map((x) => x.checked).every((x) => x === true)
|
||||
},
|
||||
formatHint: function () {
|
||||
if (this.groupby == "recipe") {
|
||||
return this.formatCategory
|
||||
} else {
|
||||
return this.formatRecipe
|
||||
}
|
||||
},
|
||||
formatFood: function () {
|
||||
return this.formatOneFood(this.entries[0])
|
||||
},
|
||||
formatUnit: function () {
|
||||
return this.formatOneUnit(this.entries[0])
|
||||
},
|
||||
formatRecipe: function () {
|
||||
if (this.entries?.length == 1) {
|
||||
return this.formatOneMealPlan(this.entries[0]) || ""
|
||||
} else {
|
||||
let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name)
|
||||
// return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ")
|
||||
|
||||
return mealplan_name
|
||||
.map((x) => {
|
||||
return this.formatOneMealPlan(x)
|
||||
computed: {
|
||||
formatAmount: function () {
|
||||
let amount = {}
|
||||
this.entries.forEach((entry) => {
|
||||
let unit = entry?.unit?.name ?? "----"
|
||||
if (entry.amount) {
|
||||
if (amount[unit]) {
|
||||
amount[unit] += entry.amount
|
||||
} else {
|
||||
amount[unit] = entry.amount
|
||||
}
|
||||
}
|
||||
})
|
||||
.join(" - ")
|
||||
}
|
||||
},
|
||||
formatNotes: function () {
|
||||
if (this.entries?.length == 1) {
|
||||
return this.formatOneNote(this.entries[0]) || ""
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
watch: {},
|
||||
mounted() {
|
||||
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
|
||||
},
|
||||
methods: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
for (const [k, v] of Object.entries(amount)) {
|
||||
amount[k] = Math.round(v * 100 + Number.EPSILON) / 100 // javascript hack to force rounding at 2 places
|
||||
}
|
||||
return amount
|
||||
},
|
||||
formatCategory: function () {
|
||||
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
|
||||
},
|
||||
formatChecked: function () {
|
||||
return this.entries.map((x) => x.checked).every((x) => x === true)
|
||||
},
|
||||
formatHint: function () {
|
||||
if (this.groupby == "recipe") {
|
||||
return this.formatCategory
|
||||
} else {
|
||||
return this.formatRecipe
|
||||
}
|
||||
},
|
||||
formatFood: function () {
|
||||
return this.formatOneFood(this.entries[0])
|
||||
},
|
||||
formatUnit: function () {
|
||||
return this.formatOneUnit(this.entries[0])
|
||||
},
|
||||
formatRecipe: function () {
|
||||
if (this.entries?.length == 1) {
|
||||
return this.formatOneMealPlan(this.entries[0]) || ""
|
||||
} else {
|
||||
let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name)
|
||||
// return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ")
|
||||
|
||||
formatDate: function (datetime) {
|
||||
if (!datetime) {
|
||||
return
|
||||
}
|
||||
return Intl.DateTimeFormat(window.navigator.language, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short"
|
||||
}).format(Date.parse(datetime))
|
||||
return mealplan_name
|
||||
.map((x) => {
|
||||
return this.formatOneMealPlan(x)
|
||||
})
|
||||
.join(" - ")
|
||||
}
|
||||
},
|
||||
formatNotes: function () {
|
||||
if (this.entries?.length == 1) {
|
||||
return this.formatOneNote(this.entries[0]) || ""
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
formatOneAmount: function (item) {
|
||||
return item?.amount ?? 1
|
||||
watch: {},
|
||||
mounted() {
|
||||
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
|
||||
},
|
||||
formatOneUnit: function (item) {
|
||||
return item?.unit?.name ?? ""
|
||||
methods: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
|
||||
formatDate: function (datetime) {
|
||||
if (!datetime) {
|
||||
return
|
||||
}
|
||||
return Intl.DateTimeFormat(window.navigator.language, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(Date.parse(datetime))
|
||||
},
|
||||
formatOneAmount: function (item) {
|
||||
return item?.amount ?? 1
|
||||
},
|
||||
formatOneUnit: function (item) {
|
||||
return item?.unit?.name ?? ""
|
||||
},
|
||||
formatOneCategory: function (item) {
|
||||
return item?.food?.supermarket_category?.name
|
||||
},
|
||||
formatOneCompletedAt: function (item) {
|
||||
if (!item.completed_at) {
|
||||
return false
|
||||
}
|
||||
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
|
||||
},
|
||||
formatOneFood: function (item) {
|
||||
return item.food.name
|
||||
},
|
||||
formatOneDelayUntil: function (item) {
|
||||
if (!item.delay_until || (item.delay_until && item.checked)) {
|
||||
return false
|
||||
}
|
||||
return [this.$t("DelayUntil"), "-", this.formatDate(item.delay_until)].join(" ")
|
||||
},
|
||||
formatOneMealPlan: function (item) {
|
||||
return item?.recipe_mealplan?.name ?? ""
|
||||
},
|
||||
formatOneRecipe: function (item) {
|
||||
return item?.recipe_mealplan?.recipe_name ?? ""
|
||||
},
|
||||
formatOneNote: function (item) {
|
||||
if (!item) {
|
||||
item = this.entries[0]
|
||||
}
|
||||
return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note].filter(String)
|
||||
},
|
||||
formatOneCreatedBy: function (item) {
|
||||
return [this.$t("Added_by"), item?.created_by.username, "@", this.formatDate(item.created_at)].join(" ")
|
||||
},
|
||||
openRecipeCard: function (e, item) {
|
||||
this.genericAPI(this.Models.RECIPE, this.Actions.FETCH, { id: item.recipe_mealplan.recipe }).then((result) => {
|
||||
let recipe = result.data
|
||||
recipe.steps = undefined
|
||||
this.recipe = true
|
||||
this.$refs.recipe_card.open(e, recipe)
|
||||
})
|
||||
},
|
||||
updateChecked: function (e, item) {
|
||||
let update = undefined
|
||||
if (!item) {
|
||||
update = { entries: this.entries.map((x) => x.id), checked: !this.formatChecked }
|
||||
} else {
|
||||
update = { entries: [item], checked: !item.checked }
|
||||
}
|
||||
this.$emit("update-checkbox", update)
|
||||
},
|
||||
},
|
||||
formatOneCategory: function (item) {
|
||||
return item?.food?.supermarket_category?.name
|
||||
},
|
||||
formatOneCompletedAt: function (item) {
|
||||
if (!item.completed_at) {
|
||||
return false
|
||||
}
|
||||
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
|
||||
},
|
||||
formatOneFood: function (item) {
|
||||
return item.food.name
|
||||
},
|
||||
formatOneDelayUntil: function (item) {
|
||||
if (!item.delay_until || (item.delay_until && item.checked)) {
|
||||
return false
|
||||
}
|
||||
return [this.$t("DelayUntil"), "-", this.formatDate(item.delay_until)].join(" ")
|
||||
},
|
||||
formatOneMealPlan: function (item) {
|
||||
return item?.recipe_mealplan?.name ?? ""
|
||||
},
|
||||
formatOneRecipe: function (item) {
|
||||
return item?.recipe_mealplan?.recipe_name ?? ""
|
||||
},
|
||||
formatOneNote: function (item) {
|
||||
if (!item) {
|
||||
item = this.entries[0]
|
||||
}
|
||||
return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note].filter(String)
|
||||
},
|
||||
formatOneCreatedBy: function (item) {
|
||||
return [this.$t("Added_by"), item?.created_by.username, "@", this.formatDate(item.created_at)].join(" ")
|
||||
},
|
||||
openRecipeCard: function (e, item) {
|
||||
this.genericAPI(this.Models.RECIPE, this.Actions.FETCH, {id: item.recipe_mealplan.recipe}).then((result) => {
|
||||
let recipe = result.data
|
||||
recipe.steps = undefined
|
||||
this.recipe = true
|
||||
this.$refs.recipe_card.open(e, recipe)
|
||||
})
|
||||
},
|
||||
updateChecked: function (e, item) {
|
||||
let update = undefined
|
||||
if (!item) {
|
||||
update = {entries: this.entries.map((x) => x.id), checked: !this.formatChecked}
|
||||
} else {
|
||||
update = {entries: [item], checked: !item.checked}
|
||||
}
|
||||
this.$emit("update-checkbox", update)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -344,34 +321,34 @@ export default {
|
||||
/* border-bottom: 1px solid #000; /* …and with a border on the top */
|
||||
/* } */
|
||||
.checkbox-control {
|
||||
font-size: 0.6rem
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.checkbox-control-mobile {
|
||||
font-size: 1rem
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.rotate {
|
||||
-moz-transition: all 0.25s linear;
|
||||
-webkit-transition: all 0.25s linear;
|
||||
transition: all 0.25s linear;
|
||||
-moz-transition: all 0.25s linear;
|
||||
-webkit-transition: all 0.25s linear;
|
||||
transition: all 0.25s linear;
|
||||
}
|
||||
|
||||
.rotated {
|
||||
-moz-transform: rotate(90deg);
|
||||
-webkit-transform: rotate(90deg);
|
||||
transform: rotate(90deg);
|
||||
-moz-transform: rotate(90deg);
|
||||
-webkit-transform: rotate(90deg);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.unit-badge-lg {
|
||||
font-size: 1rem !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 1rem !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dropdown-spacing {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
.dropdown-spacing {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,9 +32,8 @@
|
||||
|
||||
<div class="row">
|
||||
<!-- ingredients table -->
|
||||
<div class="col col-md-4">
|
||||
<table class="table table-sm"
|
||||
v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
|
||||
<div class="col col-md-4" v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
|
||||
<table class="table table-sm" >
|
||||
<ingredients-card :steps="[step]" :ingredient_factor="ingredient_factor"
|
||||
@checked-state-changed="$emit('checked-state-changed', $event)"/>
|
||||
</table>
|
||||
|
||||
@@ -127,8 +127,8 @@
|
||||
"Hide_as_header": "Keine Überschrift",
|
||||
"Copy_template_reference": "Template Referenz kopieren",
|
||||
"Step_Type": "Schritt Typ",
|
||||
"Make_Ingredient": "In Zutat wandeln",
|
||||
"Make_Header": "In Überschrift wandeln",
|
||||
"Make_Ingredient": "In Zutat wandeln",
|
||||
"Enable_Amount": "Menge aktivieren",
|
||||
"Disable_Amount": "Menge deaktivieren",
|
||||
"Add_Step": "Schritt hinzufügen",
|
||||
@@ -186,7 +186,6 @@
|
||||
"Edit_Meal_Plan_Entry": "Eintrag bearbeiten",
|
||||
"Create_New_Meal_Type": "Neue Mahlzeit",
|
||||
"Create_Meal_Plan_Entry": "Neuer Eintrag",
|
||||
"Make_header": "Erstelle Überschrift",
|
||||
"Color": "Farbe",
|
||||
"New_Meal_Type": "Neue Mahlzeit",
|
||||
"Periods": "Zeiträume",
|
||||
@@ -249,5 +248,48 @@
|
||||
"Search Settings": "Sucheinstellungen",
|
||||
"shopping_auto_sync_desc": "Bei 0 wird Auto-Sync deaktiviert. Beim Betrachten einer Einkaufsliste wird die Liste alle gesetzten Sekunden aktualisiert, um mögliche Änderungen anderer zu zeigen. Nützlich, wenn mehrere Personen einkaufen und mobile Daten nutzen.",
|
||||
"MoveCategory": "Verschieben nach: ",
|
||||
"mealplan_autoadd_shopping_desc": "Essensplan-Zutaten automatisch zur Einkaufsliste hinzufügen."
|
||||
"mealplan_autoadd_shopping_desc": "Essensplan-Zutaten automatisch zur Einkaufsliste hinzufügen.",
|
||||
"Pin": "Pin",
|
||||
"mark_complete": "Vollständig markieren",
|
||||
"shopping_add_onhand_desc": "Markiere Lebensmittel als \"Vorrätig\", wenn von der Einkaufsliste abgehakt wurden.",
|
||||
"left_handed": "Linkshändermodus",
|
||||
"left_handed_help": "Optimiert die Benutzeroberfläche für die Bedienung mit der linken Hand.",
|
||||
"FoodInherit": "Lebensmittel vererbbare Felder",
|
||||
"SupermarketCategoriesOnly": "Nur Supermarkt Kategorien",
|
||||
"InheritWarning": "{food} ist auf Vererbung gesetzt ist, Änderungen werden möglicherweise nicht gespeichert.",
|
||||
"mealplan_autoexclude_onhand_desc": "Wenn Sie einen Essensplan zur Einkaufsliste hinzufügen (manuell oder automatisch), schließen Sie Zutaten aus, die gerade vorrätig sind.",
|
||||
"mealplan_autoinclude_related_desc": "Wenn Sie einen Essensplan zur Einkaufsliste hinzufügen (manuell oder automatisch), fügen Sie alle zugehörigen Rezepte hinzu.",
|
||||
"default_delay_desc": "Voreingestellte Anzahl von Stunden für die Verzögerung eines Einkaufslisteneintrags.",
|
||||
"filter_to_supermarket": "Auf Supermarkt filtern",
|
||||
"err_move_self": "Element kann nicht auf sich selbst verschoben werden",
|
||||
"nothing": "Nichts zu tun",
|
||||
"err_merge_self": "Element kann nicht mit sich selbst zusammengeführt werden",
|
||||
"show_sql": "SQL anzeigen",
|
||||
"filter_to_supermarket_desc": "Standardmäßig wird die Einkaufsliste so gefiltert, dass sie nur Kategorien für den ausgewählten Supermarkt enthält.",
|
||||
"CategoryName": "Kategorie Name",
|
||||
"SupermarketName": "Supermarkt Name",
|
||||
"CategoryInstruction": "Ziehen Sie Kategorien, um die Reihenfolge zu ändern, in der die Kategorien in der Einkaufsliste erscheinen.",
|
||||
"shopping_recent_days_desc": "Tage der letzten Einträge in der Einkaufsliste, die angezeigt werden sollen.",
|
||||
"shopping_recent_days": "Letzte Tage",
|
||||
"create_shopping_new": "Zur NEUEN Einkaufsliste hinzufügen",
|
||||
"download_pdf": "PDF herunterladen",
|
||||
"download_csv": "CSV herunterladen",
|
||||
"csv_delim_help": "Trennzeichen für CSV-Exporte.",
|
||||
"csv_delim_label": "CSV-Trennzeichen",
|
||||
"SuccessClipboard": "Einkaufsliste wurde in die Zwischenablage kopiert",
|
||||
"copy_to_clipboard": "In die Zwischenablage kopieren",
|
||||
"csv_prefix_help": "Präfix, das beim Kopieren der Liste in die Zwischenablage hinzugefügt wird.",
|
||||
"csv_prefix_label": "Listenpräfix",
|
||||
"copy_markdown_table": "Als Markdown-Tabelle kopieren",
|
||||
"in_shopping": "In Einkaufsliste",
|
||||
"DelayUntil": "Verzögerung bis",
|
||||
"QuickEntry": "Schnelleinstieg",
|
||||
"shopping_add_onhand": "Automatisch vorrätig",
|
||||
"related_recipes": "Ähnliche Rezepte",
|
||||
"today_recipes": "Rezepte des Tages",
|
||||
"sql_debug": "SQL Debug",
|
||||
"remember_search": "Suchbegriff merken",
|
||||
"remember_hours": "Stunden zu erinnern",
|
||||
"tree_select": "Baum-Auswahl verwenden",
|
||||
"CountMore": "...+{count} weitere"
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@
|
||||
"Move_Down": "Move down",
|
||||
"Step_Name": "Step Name",
|
||||
"Step_Type": "Step Type",
|
||||
"Make_header": "Make_Header",
|
||||
"Make_Ingredient": "Make_Ingredient",
|
||||
"Make_Header": "Make Header",
|
||||
"Make_Ingredient": "Make Ingredient",
|
||||
"Enable_Amount": "Enable Amount",
|
||||
"Disable_Amount": "Disable Amount",
|
||||
"Add_Step": "Add Step",
|
||||
@@ -289,5 +289,12 @@
|
||||
"remember_hours": "Hours to Remember",
|
||||
"tree_select": "Use Tree Selection",
|
||||
"left_handed": "Left-handed mode",
|
||||
"left_handed_help": "Will optimize the UI for use with your left hand."
|
||||
"left_handed_help": "Will optimize the UI for use with your left hand.",
|
||||
"OnHand_help": "Food is in inventory and will not be automatically added to a shopping list.",
|
||||
"ignore_shopping_help": "Never add food to the shopping list (e.g. water)",
|
||||
"shopping_category_help": "Supermarkets can be ordered and filtered by Shopping Category according to the layout of the aisles.",
|
||||
"food_recipe_help": "Linking a recipe here will include the linked recipe in any other recipe that use this food",
|
||||
"Foods": "Foods",
|
||||
"review_shopping": "Review shopping entries before saving",
|
||||
"view_recipe": "View Recipe"
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
"Move_Down": "Siirry alas",
|
||||
"Step_Name": "Vaiheen Nimi",
|
||||
"Step_Type": "Vaiheen Tyyppi",
|
||||
"Make_header": "Valmista_Otsikko",
|
||||
"Make_Ingredient": "Valmista_Ainesosa",
|
||||
"Make_Header": "Valmista Otsikko",
|
||||
"Make_Ingredient": "Valmista Ainesosa",
|
||||
"Enable_Amount": "Ota Määrä käyttöön",
|
||||
"Disable_Amount": "Poista Määrä käytöstä",
|
||||
"Add_Step": "Lisää Vaihe",
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"Key_Ctrl": "Ctrl",
|
||||
"Add_nutrition_recipe": "Ajouter les valeurs nutritionelles à la recette",
|
||||
"Remove_nutrition_recipe": "Supprimer les valeurs nutritionelles de la recette",
|
||||
"Make_Header": "Créer une en-tête",
|
||||
"Make_Ingredient": "Créer un ingrédient",
|
||||
"Enable_Amount": "Activer la quantité",
|
||||
"Disable_Amount": "Désactiver la quantité",
|
||||
@@ -151,7 +152,6 @@
|
||||
"create_rule": "et créer une automatisation",
|
||||
"Automate": "Automatiser",
|
||||
"Create_New_Meal_Type": "Ajouter un nouveau type de repas",
|
||||
"Make_header": "Créer une en-tête",
|
||||
"No_Results": "Aucun résultat",
|
||||
"Type": "Type",
|
||||
"Unit": "Unité",
|
||||
|
||||
@@ -184,7 +184,6 @@
|
||||
"Title_or_Recipe_Required": "Sono richiesti titolo o ricetta",
|
||||
"Create_Meal_Plan_Entry": "Crea voce nel piano alimentare",
|
||||
"Edit_Meal_Plan_Entry": "Modifica voce del piano alimentare",
|
||||
"Make_header": "Crea Intestazione",
|
||||
"Color": "Colore",
|
||||
"New_Meal_Type": "Nuovo tipo di pasto",
|
||||
"Select_File": "Seleziona file",
|
||||
|
||||
@@ -110,8 +110,8 @@
|
||||
"Move_Down": "Verplaats omlaag",
|
||||
"Step_Name": "Stap Naam",
|
||||
"Step_Type": "Stap Type",
|
||||
"Make_Header": "Maak_Koptekst",
|
||||
"Make_Ingredient": "Maak_Ingrediënt",
|
||||
"Make_Header": "Maak Koptekst",
|
||||
"Make_Ingredient": "Maak Ingrediënt",
|
||||
"Enable_Amount": "Schakel hoeveelheid in",
|
||||
"Disable_Amount": "Schakel hoeveelheid uit",
|
||||
"Add_Step": "Voeg Stap toe",
|
||||
@@ -171,7 +171,6 @@
|
||||
"Title": "Titel",
|
||||
"Week": "Week",
|
||||
"Month": "Maand",
|
||||
"Make_header": "Maak dit de koptekst",
|
||||
"Color": "Kleur",
|
||||
"New_Meal_Type": "Nieuw Maaltype",
|
||||
"Image": "Afbeelding",
|
||||
|
||||
@@ -172,7 +172,6 @@
|
||||
"Year": "Rok",
|
||||
"Planner_Settings": "Ustawienia terminarza",
|
||||
"Planner": "Terminarz",
|
||||
"Make_header": "Utwórz nagłówek",
|
||||
"New_Meal_Type": "Nowy rodzaj posiłku",
|
||||
"Select_File": "Wybierz plik",
|
||||
"Image": "Obraz",
|
||||
@@ -285,5 +284,18 @@
|
||||
"related_recipes": "Powiązane przepisy",
|
||||
"today_recipes": "Dzisiejsze przepisy",
|
||||
"Search Settings": "Ustawienia wyszukiwania",
|
||||
"Pin": "Pin"
|
||||
"Pin": "Pin",
|
||||
"left_handed_help": "Zoptymalizuje interfejs użytkownika do użytku lewą ręką.",
|
||||
"food_recipe_help": "Powiązanie tutaj przepisu będzie skutkowało połączenie przepisu z każdym innym przepisem, który używa tego jedzenia",
|
||||
"Foods": "Żywność",
|
||||
"view_recipe": "Zobacz przepis",
|
||||
"left_handed": "Tryb dla leworęcznych",
|
||||
"OnHand_help": "Żywność jest w spiżarni i nie zostanie automatycznie dodana do listy zakupów.",
|
||||
"ignore_shopping_help": "Nigdy nie dodawaj żywności do listy zakupów (np. wody)",
|
||||
"shopping_category_help": "Z supermarketów można zamawiać i filtrować według kategorii zakupów zgodnie z układem alejek.",
|
||||
"review_shopping": "Przejrzyj wpisy zakupów przed zapisaniem",
|
||||
"sql_debug": "Debugowanie SQL",
|
||||
"remember_search": "Zapamiętaj wyszukiwanie",
|
||||
"remember_hours": "Godziny do zapamiętania",
|
||||
"tree_select": "Użyj drzewa wyboru"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"Move_Down": "",
|
||||
"Step_Name": "",
|
||||
"Step_Type": "",
|
||||
"Make_header": "",
|
||||
"Make_Header": "",
|
||||
"Make_Ingredient": "",
|
||||
"Enable_Amount": "",
|
||||
"Disable_Amount": "",
|
||||
|
||||
@@ -53,8 +53,8 @@
|
||||
"Move_Down": "Перенести вниз",
|
||||
"Step_Name": "Имя шага",
|
||||
"Step_Type": "Тип шага",
|
||||
"Make_header": "Создание_Заголовка",
|
||||
"Make_Ingredient": "Создание_инградиента",
|
||||
"Make_Header": "Создание Заголовка",
|
||||
"Make_Ingredient": "Создание инградиента",
|
||||
"Enable_Amount": "Активировать Количество",
|
||||
"Disable_Amount": "Деактивировать количество",
|
||||
"Add_Step": "Добавить шаг",
|
||||
|
||||
@@ -1,210 +1,288 @@
|
||||
{
|
||||
"warning_feature_beta": "Ta funkcija je trenutno v stanju BETA (testiranje). Pri uporabi te funkcije pričakujte napake in morebitne prelomne spremembe v prihodnosti (morda izgubite podatke, povezane s to funkcijo).",
|
||||
"err_fetching_resource": "",
|
||||
"err_creating_resource": "",
|
||||
"err_updating_resource": "",
|
||||
"err_deleting_resource": "",
|
||||
"success_fetching_resource": "",
|
||||
"success_creating_resource": "",
|
||||
"success_updating_resource": "",
|
||||
"success_deleting_resource": "",
|
||||
"file_upload_disabled": "",
|
||||
"step_time_minutes": "",
|
||||
"confirm_delete": "",
|
||||
"import_running": "",
|
||||
"all_fields_optional": "",
|
||||
"convert_internal": "",
|
||||
"show_only_internal": "",
|
||||
"show_split_screen": "",
|
||||
"Log_Recipe_Cooking": "",
|
||||
"External_Recipe_Image": "",
|
||||
"Add_to_Shopping": "",
|
||||
"Add_to_Plan": "",
|
||||
"Step_start_time": "",
|
||||
"Sort_by_new": "",
|
||||
"Table_of_Contents": "",
|
||||
"Recipes_per_page": "",
|
||||
"Show_as_header": "",
|
||||
"Hide_as_header": "",
|
||||
"Add_nutrition_recipe": "",
|
||||
"Remove_nutrition_recipe": "",
|
||||
"Copy_template_reference": "",
|
||||
"Save_and_View": "",
|
||||
"Manage_Books": "",
|
||||
"Meal_Plan": "",
|
||||
"Select_Book": "",
|
||||
"Select_File": "",
|
||||
"Recipe_Image": "",
|
||||
"Import_finished": "",
|
||||
"View_Recipes": "",
|
||||
"Log_Cooking": "",
|
||||
"err_fetching_resource": "Napaka pri pridobivanju vira!",
|
||||
"err_creating_resource": "Napaka pri ustvarjanju vira!",
|
||||
"err_updating_resource": "Napaka pri posodabljanju vira!",
|
||||
"err_deleting_resource": "Napaka pri brisanju vira!",
|
||||
"success_fetching_resource": "Pridobivanje vira je bilo uspešno!",
|
||||
"success_creating_resource": "Ustvarjanje vira je bilo uspešno!",
|
||||
"success_updating_resource": "Posodabljanje vira je bilo uspešno!",
|
||||
"success_deleting_resource": "Brisanje vira je bilo uspešno!",
|
||||
"file_upload_disabled": "Nalaganje datoteke ni omogočeno za tvoj prostor.",
|
||||
"step_time_minutes": "Časovni korak v minutah",
|
||||
"confirm_delete": "Ali si prepričan da želiš izbrisati {object}?",
|
||||
"import_running": "Uvoz poteka, prosim počakaj!",
|
||||
"all_fields_optional": "Vsa polja so opcijska in jih lahko pustiš prazne.",
|
||||
"convert_internal": "Pretvori v interni recept",
|
||||
"show_only_internal": "Prikaži samo interne recepte",
|
||||
"show_split_screen": "Deljen pogled",
|
||||
"Log_Recipe_Cooking": "Logiraj recept za kuhanje",
|
||||
"External_Recipe_Image": "Zunanja slika recepta",
|
||||
"Add_to_Shopping": "Dodaj v nakupovalni listek",
|
||||
"Add_to_Plan": "Dodaj v načrt",
|
||||
"Step_start_time": "Začetni čas koraka",
|
||||
"Sort_by_new": "Razvrsti po novih",
|
||||
"Table_of_Contents": "Kazalo vsebine",
|
||||
"Recipes_per_page": "Receptov na stran",
|
||||
"Show_as_header": "Prikaži kot glavo",
|
||||
"Hide_as_header": "Skrij kot glavo",
|
||||
"Add_nutrition_recipe": "Receptu dodaj hranilno vrednost",
|
||||
"Remove_nutrition_recipe": "Receptu izbriši hranilno vrednost",
|
||||
"Copy_template_reference": "Kopiraj referenco vzorca",
|
||||
"Save_and_View": "Shrani in poglej",
|
||||
"Manage_Books": "Upravljaj knjige",
|
||||
"Meal_Plan": "Načrt obroka",
|
||||
"Select_Book": "Izberi knjigo",
|
||||
"Select_File": "Izberi datoteko",
|
||||
"Recipe_Image": "Slika recepta",
|
||||
"Import_finished": "Uvoz je končan",
|
||||
"View_Recipes": "Preglej recepte",
|
||||
"Log_Cooking": "Zgodovina kuhanja",
|
||||
"New_Recipe": "Nov Recept",
|
||||
"Url_Import": "",
|
||||
"Reset_Search": "",
|
||||
"Recently_Viewed": "",
|
||||
"Load_More": "",
|
||||
"New_Keyword": "",
|
||||
"Delete_Keyword": "",
|
||||
"Edit_Keyword": "",
|
||||
"Url_Import": "URL uvoz",
|
||||
"Reset_Search": "Ponastavi iskalnik",
|
||||
"Recently_Viewed": "Nazadnje videno",
|
||||
"Load_More": "Naloži več",
|
||||
"New_Keyword": "Nova ključna beseda",
|
||||
"Delete_Keyword": "Izbriši ključno besedo",
|
||||
"Edit_Keyword": "Uredi ključno besedo",
|
||||
"Edit_Recipe": "Uredi Recept",
|
||||
"Move_Keyword": "",
|
||||
"Merge_Keyword": "",
|
||||
"Hide_Keywords": "",
|
||||
"Hide_Recipes": "",
|
||||
"Move_Up": "",
|
||||
"Move_Down": "",
|
||||
"Step_Name": "",
|
||||
"Step_Type": "",
|
||||
"Make_header": "",
|
||||
"Make_Ingredient": "",
|
||||
"Enable_Amount": "",
|
||||
"Disable_Amount": "",
|
||||
"Add_Step": "",
|
||||
"Keywords": "",
|
||||
"Move_Keyword": "Premakni ključno besedo",
|
||||
"Merge_Keyword": "Združi ključno besedo",
|
||||
"Hide_Keywords": "Skrij ključno besedo",
|
||||
"Hide_Recipes": "Skrij recept",
|
||||
"Move_Up": "Premakni navzgor",
|
||||
"Move_Down": "Premakni navzdol",
|
||||
"Step_Name": "Ime koraka",
|
||||
"Step_Type": "Tip koraka",
|
||||
"Make_Header": "Ustvari glavo",
|
||||
"Make_Ingredient": "Ustvari sestavino",
|
||||
"Enable_Amount": "Omogoči količino",
|
||||
"Disable_Amount": "Onemogoči količino",
|
||||
"Add_Step": "Dodaj korak",
|
||||
"Keywords": "Ključne besede",
|
||||
"Books": "Knjige",
|
||||
"Proteins": "",
|
||||
"Fats": "",
|
||||
"Carbohydrates": "",
|
||||
"Calories": "",
|
||||
"Energy": "",
|
||||
"Nutrition": "",
|
||||
"Proteins": "Beljakovine",
|
||||
"Fats": "Maščobe",
|
||||
"Carbohydrates": "Ogljikovi hidrati",
|
||||
"Calories": "Kalorije",
|
||||
"Energy": "Energija",
|
||||
"Nutrition": "Prehrana",
|
||||
"Date": "Datum",
|
||||
"Share": "Deli",
|
||||
"Automation": "",
|
||||
"Parameter": "",
|
||||
"Export": "",
|
||||
"Copy": "",
|
||||
"Rating": "",
|
||||
"Close": "",
|
||||
"Cancel": "",
|
||||
"Link": "",
|
||||
"Add": "",
|
||||
"New": "",
|
||||
"Note": "",
|
||||
"Success": "",
|
||||
"Failure": "",
|
||||
"Ingredients": "",
|
||||
"Supermarket": "",
|
||||
"Categories": "",
|
||||
"Category": "",
|
||||
"Selected": "",
|
||||
"min": "",
|
||||
"Servings": "",
|
||||
"Waiting": "",
|
||||
"Preparation": "",
|
||||
"External": "",
|
||||
"Size": "",
|
||||
"Files": "",
|
||||
"File": "",
|
||||
"Edit": "",
|
||||
"Image": "",
|
||||
"Automation": "Avtomatizacija",
|
||||
"Parameter": "Parameter",
|
||||
"Export": "Izvoz",
|
||||
"Copy": "Kopiraj",
|
||||
"Rating": "Ocena",
|
||||
"Close": "Zapri",
|
||||
"Cancel": "Prekini",
|
||||
"Link": "Hiperpovezava",
|
||||
"Add": "Dodaj",
|
||||
"New": "Nov",
|
||||
"Note": "Opomba",
|
||||
"Success": "Uspešno",
|
||||
"Failure": "Napaka",
|
||||
"Ingredients": "Sestavine",
|
||||
"Supermarket": "Supermarket",
|
||||
"Categories": "Kategorije",
|
||||
"Category": "Kategorija",
|
||||
"Selected": "Izbrano",
|
||||
"min": "min",
|
||||
"Servings": "Porcije",
|
||||
"Waiting": "Čakanje",
|
||||
"Preparation": "Priprava",
|
||||
"External": "Zunanje",
|
||||
"Size": "Velikost",
|
||||
"Files": "Datoteke",
|
||||
"File": "Datoteka",
|
||||
"Edit": "Uredi",
|
||||
"Image": "Slika",
|
||||
"Delete": "Izbriši",
|
||||
"Open": "Odpri",
|
||||
"Ok": "Odpri",
|
||||
"Save": "Shrani",
|
||||
"Step": "",
|
||||
"Step": "Korak",
|
||||
"Search": "Iskanje",
|
||||
"Import": "Uvozi",
|
||||
"Print": "Natisni",
|
||||
"Settings": "",
|
||||
"or": "",
|
||||
"and": "",
|
||||
"Information": "",
|
||||
"Settings": "Nastavitve",
|
||||
"or": "ali",
|
||||
"and": "in",
|
||||
"Information": "Informacija",
|
||||
"Download": "Prenesi",
|
||||
"Create": "",
|
||||
"Create": "Ustvari",
|
||||
"Advanced Search Settings": "",
|
||||
"View": "",
|
||||
"View": "Pogled",
|
||||
"Recipes": "Recepti",
|
||||
"Move": "",
|
||||
"Merge": "",
|
||||
"Parent": "",
|
||||
"delete_confirmation": "",
|
||||
"move_confirmation": "",
|
||||
"merge_confirmation": "",
|
||||
"create_rule": "",
|
||||
"move_selection": "",
|
||||
"merge_selection": "",
|
||||
"Move": "Premakni",
|
||||
"Merge": "Združi",
|
||||
"Parent": "Starš",
|
||||
"delete_confirmation": "Ste prepričani da želite odstraniti {source}?",
|
||||
"move_confirmation": "Premakni <i>{child}</i> k staršu <i>{parent}</i>",
|
||||
"merge_confirmation": "Zamenjaj <i>{source}</i> z/s <i>{target}</i>",
|
||||
"create_rule": "in ustvari avtomatizacijo",
|
||||
"move_selection": "Izberi starša {type} za premik v {source}.",
|
||||
"merge_selection": "Zamenjaj vse dogodge {source} z izbranim {type}.",
|
||||
"Root": "",
|
||||
"Ignore_Shopping": "",
|
||||
"Shopping_Category": "",
|
||||
"Edit_Food": "",
|
||||
"Move_Food": "",
|
||||
"New_Food": "",
|
||||
"Hide_Food": "",
|
||||
"Food_Alias": "",
|
||||
"Unit_Alias": "",
|
||||
"Keyword_Alias": "",
|
||||
"Delete_Food": "",
|
||||
"No_ID": "",
|
||||
"Meal_Plan_Days": "",
|
||||
"merge_title": "",
|
||||
"move_title": "",
|
||||
"Ignore_Shopping": "Prezri nakup",
|
||||
"Shopping_Category": "Kategorija nakupa",
|
||||
"Edit_Food": "Uredi hrano",
|
||||
"Move_Food": "Premakni hrano",
|
||||
"New_Food": "Nova hrana",
|
||||
"Hide_Food": "Skrij hrano",
|
||||
"Food_Alias": "Vzdevek hrane",
|
||||
"Unit_Alias": "Vzdevek enote",
|
||||
"Keyword_Alias": "Vzdevek ključne besede",
|
||||
"Delete_Food": "Izbriši hrano",
|
||||
"No_ID": "ID ni najden, ne morem izbrisati.",
|
||||
"Meal_Plan_Days": "Načrt za prihodnje obroke",
|
||||
"merge_title": "Združi {type}",
|
||||
"move_title": "Premakni {type}",
|
||||
"Food": "Hrana",
|
||||
"Recipe_Book": "",
|
||||
"del_confirmation_tree": "",
|
||||
"delete_title": "",
|
||||
"create_title": "",
|
||||
"edit_title": "",
|
||||
"Name": "",
|
||||
"Type": "",
|
||||
"Description": "",
|
||||
"Recipe": "",
|
||||
"Recipe_Book": "Knjiga receptov",
|
||||
"del_confirmation_tree": "Si prepričan/a, da želiš izbrisati {source} in vse podkategorije?",
|
||||
"delete_title": "Izbriši {type}",
|
||||
"create_title": "Novo {type}",
|
||||
"edit_title": "Uredi {type}",
|
||||
"Name": "Ime",
|
||||
"Type": "Tip",
|
||||
"Description": "Opis",
|
||||
"Recipe": "Recept",
|
||||
"tree_root": "",
|
||||
"Icon": "",
|
||||
"Unit": "",
|
||||
"No_Results": "",
|
||||
"New_Unit": "",
|
||||
"Create_New_Shopping Category": "",
|
||||
"Icon": "Ikona",
|
||||
"Unit": "Enota",
|
||||
"No_Results": "Ni rezultatov",
|
||||
"New_Unit": "Nova enota",
|
||||
"Create_New_Shopping Category": "Ustvari novo kategorijo nakupovalnega listka",
|
||||
"Create_New_Food": "Dodaj Novo Hrano",
|
||||
"Create_New_Keyword": "",
|
||||
"Create_New_Unit": "",
|
||||
"Create_New_Meal_Type": "",
|
||||
"and_up": "",
|
||||
"Instructions": "",
|
||||
"Unrated": "",
|
||||
"Automate": "",
|
||||
"Empty": "",
|
||||
"Key_Ctrl": "",
|
||||
"Key_Shift": "",
|
||||
"Time": "",
|
||||
"Text": "",
|
||||
"Create_New_Keyword": "Dodaj novo ključno besedo",
|
||||
"Create_New_Unit": "Dodaj novo enoto",
|
||||
"Create_New_Meal_Type": "Dodaj nov tip obroka",
|
||||
"and_up": "& gor",
|
||||
"Instructions": "Navodila",
|
||||
"Unrated": "Neocenjeno",
|
||||
"Automate": "Avtomatiziraj",
|
||||
"Empty": "Prazno",
|
||||
"Key_Ctrl": "Ctrl",
|
||||
"Key_Shift": "Shift",
|
||||
"Time": "Čas",
|
||||
"Text": "Tekst",
|
||||
"Shopping_list": "Nakupovalni Seznam",
|
||||
"Create_Meal_Plan_Entry": "",
|
||||
"Edit_Meal_Plan_Entry": "",
|
||||
"Title": "",
|
||||
"Create_Meal_Plan_Entry": "Ustvari vnos za načrtovan obrok",
|
||||
"Edit_Meal_Plan_Entry": "Spremeni vnos za načrtovan obrok",
|
||||
"Title": "Naslov",
|
||||
"Week": "Teden",
|
||||
"Month": "Mesec",
|
||||
"Year": "Leto",
|
||||
"Planner": "",
|
||||
"Planner_Settings": "",
|
||||
"Period": "",
|
||||
"Plan_Period_To_Show": "",
|
||||
"Periods": "",
|
||||
"Plan_Show_How_Many_Periods": "",
|
||||
"Starting_Day": "",
|
||||
"Meal_Types": "",
|
||||
"Meal_Type": "",
|
||||
"Clone": "",
|
||||
"Drag_Here_To_Delete": "",
|
||||
"Meal_Type_Required": "",
|
||||
"Title_or_Recipe_Required": "",
|
||||
"Planner": "Planer",
|
||||
"Planner_Settings": "Nastavitve planerja",
|
||||
"Period": "Obdobje",
|
||||
"Plan_Period_To_Show": "Prikaži, tedne, mesece ali leta",
|
||||
"Periods": "Obdobja",
|
||||
"Plan_Show_How_Many_Periods": "Koliko obdobij prikažem",
|
||||
"Starting_Day": "Začetni dan v tednu",
|
||||
"Meal_Types": "Tipi obroka",
|
||||
"Meal_Type": "Tip obroka",
|
||||
"Clone": "Kloniraj",
|
||||
"Drag_Here_To_Delete": "Povleci sem za izbris",
|
||||
"Meal_Type_Required": "Tip obroka je obvezen",
|
||||
"Title_or_Recipe_Required": "Zahtevan je naslov ali izbran recept",
|
||||
"Color": "Barva",
|
||||
"New_Meal_Type": "",
|
||||
"Week_Numbers": "",
|
||||
"Show_Week_Numbers": "",
|
||||
"Export_As_ICal": "",
|
||||
"Export_To_ICal": "",
|
||||
"Cannot_Add_Notes_To_Shopping": "",
|
||||
"Added_To_Shopping_List": "",
|
||||
"Shopping_List_Empty": "",
|
||||
"Next_Period": "",
|
||||
"Previous_Period": "",
|
||||
"Current_Period": "",
|
||||
"New_Meal_Type": "Nov tip obroka",
|
||||
"Week_Numbers": "Števila tednov",
|
||||
"Show_Week_Numbers": "Prikaži število tednov?",
|
||||
"Export_As_ICal": "Izvozi trenutno obdobje v iCal format",
|
||||
"Export_To_ICal": "Izvoz.ics",
|
||||
"Cannot_Add_Notes_To_Shopping": "Opombe ne moreš dodati v nakupovalni listek",
|
||||
"Added_To_Shopping_List": "Dodano v nakupovalni listek",
|
||||
"Shopping_List_Empty": "Tvoj nakupovalni listek je trenutno prazen. Stvari lahko dodaš preko menija za načrt obroka (desni klik na kartico ali levi klik na ikono za meni)",
|
||||
"Next_Period": "Naslednje obdobje",
|
||||
"Previous_Period": "Prejšnje obdobje",
|
||||
"Current_Period": "Trenutno obdobje",
|
||||
"Next_Day": "Naslednji Dan",
|
||||
"Previous_Day": "Prejšnji Dan",
|
||||
"Coming_Soon": "",
|
||||
"Auto_Planner": "",
|
||||
"New_Cookbook": "",
|
||||
"Hide_Keyword": "",
|
||||
"Clear": ""
|
||||
"Coming_Soon": "Kmalu",
|
||||
"Auto_Planner": "Avto-planer",
|
||||
"New_Cookbook": "Nova kuharska knjiga",
|
||||
"Hide_Keyword": "Skrij ključne besede",
|
||||
"Clear": "Počisti",
|
||||
"Pin": "Pripni",
|
||||
"err_moving_resource": "Napaka pri premikanju vira!",
|
||||
"err_merging_resource": "Napaka pri združevanju vira!",
|
||||
"Shopping_Categories": "Kategorije nakupa",
|
||||
"IngredientInShopping": "Ta sestavina je v tvojem nakupovalnem listku.",
|
||||
"RemoveFoodFromShopping": "Odstrani {food} iz nakupovalnega listka",
|
||||
"SupermarketCategoriesOnly": "Prikaži samo trgovinske kategorije",
|
||||
"DelayFor": "Zamakni za {hours} ur",
|
||||
"OfflineAlert": "Si v offline načinu, nakupovalni listek se mogoče ne bo sinhroniziral.",
|
||||
"shopping_share_desc": "Uporabniki bodo videli vse elemente, ki si jih dodal v nakupovalni listek. Morajo te dodati, da vidiš njihove elemente na listku.",
|
||||
"shopping_auto_sync_desc": "Nastavitev na 0 bo onemogoča avtomatsko sinhronizacijo. Pri ogledu nakupovalnega seznama se seznam posodablja vsakih nekaj sekund za sinhronizacijo sprememb, ki jih je morda naredil nekdo drug. Uporabno pri nakupovanju z več ljudmi, vendar bo uporabljalo mobilne podatke.",
|
||||
"filter_to_supermarket_desc": "Privzeto, razvrsti nakupovalni listek, da vključi samo označene trgovine.",
|
||||
"SuccessClipboard": "Nakupovalni listek je kopiran v odložišče",
|
||||
"left_handed": "Način za levičarje",
|
||||
"left_handed_help": "Optimizira grafični vmesnik za levičarje.",
|
||||
"success_moving_resource": "Premikanje vira je bilo uspešno!",
|
||||
"success_merging_resource": "Združevanje vira je bilo uspešno!",
|
||||
"Added_by": "Dodano s strani",
|
||||
"AddToShopping": "Dodaj nakupovlanemu listku",
|
||||
"NotInShopping": "{food} ni v tvojem nakupovalnem listku.",
|
||||
"OnHand": "Trenutno imam v roki",
|
||||
"FoodOnHand": "Imaš {food} v roki.",
|
||||
"FoodNotOnHand": "Nimaš {food} v roki.",
|
||||
"Undefined": "Nedefiniran",
|
||||
"AddFoodToShopping": "Dodaj {food} v nakupovalni listek",
|
||||
"DeleteShoppingConfirm": "Si prepričan/a, da želiš odstraniti VSO {food} iz nakupovalnega listka?",
|
||||
"Inherit": "Podeduj",
|
||||
"InheritFields": "Podeduj vrednosti polja",
|
||||
"FoodInherit": "Podedovana polja hrane",
|
||||
"ShowUncategorizedFood": "Prikaži nedefinirano",
|
||||
"GroupBy": "Združi po",
|
||||
"MoveCategory": "Premakni v: ",
|
||||
"CountMore": "...+{count} več",
|
||||
"IgnoreThis": "Nikoli avtomatsko ne dodaj {food} v nakup",
|
||||
"Warning": "Opozorilo",
|
||||
"NoCategory": "Nobena kategorija ni izbrana.",
|
||||
"InheritWarning": "{food} je nastavljena na dedovanje, spremembe morda ne bodo trajale.",
|
||||
"ShowDelayed": "Prikaži zamaknjene elemente",
|
||||
"Completed": "Končano",
|
||||
"shopping_share": "Deli nakupovalni listek",
|
||||
"shopping_auto_sync": "Avtomatska sinhronizacija",
|
||||
"mealplan_autoadd_shopping": "Avtomatsko dodaj obrok v načrt",
|
||||
"mealplan_autoexclude_onhand": "Izključi hrano v roki",
|
||||
"mealplan_autoinclude_related": "Dodaj povezane recepte",
|
||||
"default_delay": "Privzete ure za zamik",
|
||||
"mealplan_autoadd_shopping_desc": "Avtomatsko dodaj sestavine načrtovanega obroka v nakupovalni listek.",
|
||||
"mealplan_autoinclude_related_desc": "Pri dodajanju načrta obrokov na nakupovalni seznam (ročno ali samodejno) vključi sestavine, ki so povezane z receptom.",
|
||||
"mealplan_autoexclude_onhand_desc": "Pri dodajanju načrta obrokov na nakupovalni seznam (ročno ali samodejno) izključite sestavine, ki so trenutno v roki.",
|
||||
"err_move_self": "Ne morem premakniti elementa v samega sebe",
|
||||
"nothing": "Ni kaj za narediti",
|
||||
"err_merge_self": "Ne morem združiti elementa v samega sebe",
|
||||
"show_sql": "Prikaži SQL",
|
||||
"CategoryName": "Ime kategorije",
|
||||
"SupermarketName": "Ime trgovine",
|
||||
"CategoryInstruction": "Povleci kategorije za spremembo vrstnega reda v nakupovalnem listku.",
|
||||
"shopping_recent_days_desc": "Dnevi nedavnih vnosov na seznamu za nakupovanje, ki jih želite prikazati.",
|
||||
"shopping_recent_days": "Nedavni dnevi",
|
||||
"create_shopping_new": "Dodaj v NOV nakupovalni listek",
|
||||
"download_pdf": "Prenesi PDF",
|
||||
"download_csv": "Prenesi CSV",
|
||||
"csv_delim_help": "Ločilo za CSV izvoz.",
|
||||
"csv_delim_label": "CSV ločilo",
|
||||
"copy_to_clipboard": "Kopiraj v odložiče",
|
||||
"csv_prefix_help": "Dodana prepona, ko kopiramo nakupovalni listek v odložišče.",
|
||||
"csv_prefix_label": "Prepona seznama",
|
||||
"copy_markdown_table": "Kopiraj kot Markdown tabela",
|
||||
"in_shopping": "V nakupovalnem listku",
|
||||
"DelayUntil": "Zamakni do",
|
||||
"shopping_add_onhand": "Avtomatsko v roki",
|
||||
"related_recipes": "Povezani recepti",
|
||||
"today_recipes": "Današnji recepti",
|
||||
"mark_complete": "Označi končano",
|
||||
"QuickEntry": "Hitri vnos",
|
||||
"Search Settings": "Išči nastavitev",
|
||||
"sql_debug": "SQL razhroščevanje",
|
||||
"remember_search": "Zapomni si iskanje",
|
||||
"remember_hours": "Ure, ki si jih zapomni",
|
||||
"tree_select": "Uporabi drevesno označbo"
|
||||
}
|
||||
|
||||
@@ -185,7 +185,8 @@
|
||||
"Plan_Show_How_Many_Periods": "要显示多少个周期",
|
||||
"Starting_Day": "一周中的第一天",
|
||||
"Meal_Types": "用餐类型",
|
||||
"Make_header": "显示注意事项",
|
||||
"Make_Header": "显示注意事项",
|
||||
"Make_Ingredient": "显示材料",
|
||||
"Color": "颜色",
|
||||
"New_Meal_Type": "新用餐类型",
|
||||
"Pin": "固定",
|
||||
@@ -210,7 +211,6 @@
|
||||
"Previous_Day": "前一天",
|
||||
"remember_hours": "需要记住的时间",
|
||||
"tree_select": "使用树形选择",
|
||||
"Make_Ingredient": "显示材料",
|
||||
"Note": "笔记",
|
||||
"Added_on": "添加到",
|
||||
"AddToShopping": "添加到购物清单",
|
||||
|
||||
@@ -59,7 +59,7 @@ export class Models {
|
||||
|
||||
// MODELS - inherits and takes precedence over MODEL_TYPES and ACTIONS
|
||||
static FOOD = {
|
||||
name: i18n.t("Food"), // *OPTIONAL* : parameters will be built model -> model_type -> default
|
||||
name: "Food", // *OPTIONAL* : parameters will be built model -> model_type -> default
|
||||
apiName: "Food", // *REQUIRED* : the name that is used in api.ts for this model
|
||||
model_type: this.TREE, // *OPTIONAL* : model specific params for api, if not present will attempt modeltype_create then default_create
|
||||
paginated: true,
|
||||
@@ -76,15 +76,17 @@ export class Models {
|
||||
// REQUIRED: unordered array of fields that can be set during create
|
||||
create: {
|
||||
// if not defined partialUpdate will use the same parameters, prepending 'id'
|
||||
params: [["name", "description", "recipe", "food_onhand", "supermarket_category", "inherit", "inherit_fields"]],
|
||||
params: [["name", "description", "recipe", "food_onhand", "supermarket_category", "inherit", "inherit_fields", "ignore_shopping"]],
|
||||
|
||||
form: {
|
||||
show_help: true,
|
||||
name: {
|
||||
form_field: true,
|
||||
type: "text",
|
||||
field: "name",
|
||||
label: i18n.t("Name"),
|
||||
placeholder: "",
|
||||
subtitle_field: "full_name",
|
||||
},
|
||||
description: {
|
||||
form_field: true,
|
||||
@@ -99,12 +101,21 @@ export class Models {
|
||||
field: "recipe",
|
||||
list: "RECIPE",
|
||||
label: i18n.t("Recipe"),
|
||||
help_text: i18n.t("food_recipe_help"),
|
||||
},
|
||||
shopping: {
|
||||
onhand: {
|
||||
form_field: true,
|
||||
type: "checkbox",
|
||||
field: "food_onhand",
|
||||
label: i18n.t("OnHand"),
|
||||
help_text: i18n.t("OnHand_help"),
|
||||
},
|
||||
ignore_shopping: {
|
||||
form_field: true,
|
||||
type: "checkbox",
|
||||
field: "ignore_shopping",
|
||||
label: i18n.t("Ignore_Shopping"),
|
||||
help_text: i18n.t("ignore_shopping_help"),
|
||||
},
|
||||
shopping_category: {
|
||||
form_field: true,
|
||||
@@ -113,6 +124,7 @@ export class Models {
|
||||
list: "SHOPPING_CATEGORY",
|
||||
label: i18n.t("Shopping_Category"),
|
||||
allow_create: true,
|
||||
help_text: i18n.t("shopping_category_help"),
|
||||
},
|
||||
inherit_fields: {
|
||||
form_field: true,
|
||||
@@ -121,12 +133,7 @@ export class Models {
|
||||
field: "inherit_fields",
|
||||
list: "FOOD_INHERIT_FIELDS",
|
||||
label: i18n.t("InheritFields"),
|
||||
condition: { field: "parent", value: true, condition: "exists" },
|
||||
},
|
||||
full_name: {
|
||||
form_field: true,
|
||||
type: "smalltext",
|
||||
field: "full_name",
|
||||
condition: { field: "food_children_exist", value: true, condition: "preference_equals" },
|
||||
},
|
||||
form_function: "FoodCreateDefault",
|
||||
},
|
||||
@@ -136,12 +143,12 @@ export class Models {
|
||||
},
|
||||
}
|
||||
static FOOD_INHERIT_FIELDS = {
|
||||
name: i18n.t("FoodInherit"),
|
||||
name: "FoodInherit",
|
||||
apiName: "FoodInheritField",
|
||||
}
|
||||
|
||||
static KEYWORD = {
|
||||
name: i18n.t("Keyword"), // *OPTIONAL: parameters will be built model -> model_type -> default
|
||||
name: "Keyword", // *OPTIONAL: parameters will be built model -> model_type -> default
|
||||
apiName: "Keyword",
|
||||
model_type: this.TREE,
|
||||
paginated: true,
|
||||
@@ -184,7 +191,7 @@ export class Models {
|
||||
}
|
||||
|
||||
static UNIT = {
|
||||
name: i18n.t("Unit"),
|
||||
name: "Unit",
|
||||
apiName: "Unit",
|
||||
paginated: true,
|
||||
create: {
|
||||
@@ -210,7 +217,7 @@ export class Models {
|
||||
}
|
||||
|
||||
static SHOPPING_LIST = {
|
||||
name: i18n.t("Shopping_list"),
|
||||
name: "Shopping_list",
|
||||
apiName: "ShoppingListEntry",
|
||||
list: {
|
||||
params: ["id", "checked", "supermarket", "options"],
|
||||
@@ -239,7 +246,7 @@ export class Models {
|
||||
}
|
||||
|
||||
static RECIPE_BOOK = {
|
||||
name: i18n.t("Recipe_Book"),
|
||||
name: "Recipe_Book",
|
||||
apiName: "RecipeBook",
|
||||
create: {
|
||||
params: [["name", "description", "icon"]],
|
||||
@@ -269,7 +276,7 @@ export class Models {
|
||||
}
|
||||
|
||||
static SHOPPING_CATEGORY = {
|
||||
name: i18n.t("Shopping_Category"),
|
||||
name: "Shopping_Category",
|
||||
apiName: "SupermarketCategory",
|
||||
create: {
|
||||
params: [["name", "description"]],
|
||||
@@ -293,7 +300,7 @@ export class Models {
|
||||
}
|
||||
|
||||
static SHOPPING_CATEGORY_RELATION = {
|
||||
name: i18n.t("Shopping_Category_Relation"),
|
||||
name: "Shopping_Category_Relation",
|
||||
apiName: "SupermarketCategoryRelation",
|
||||
create: {
|
||||
params: [["category", "supermarket", "order"]],
|
||||
@@ -317,7 +324,7 @@ export class Models {
|
||||
}
|
||||
|
||||
static SUPERMARKET = {
|
||||
name: i18n.t("Supermarket"),
|
||||
name: "Supermarket",
|
||||
apiName: "Supermarket",
|
||||
ordered_tags: [{ field: "category_to_supermarket", label: "category::name", color: "info" }],
|
||||
create: {
|
||||
@@ -360,7 +367,7 @@ export class Models {
|
||||
}
|
||||
|
||||
static AUTOMATION = {
|
||||
name: i18n.t("Automation"),
|
||||
name: "Automation",
|
||||
apiName: "Automation",
|
||||
paginated: true,
|
||||
list: {
|
||||
@@ -423,7 +430,7 @@ export class Models {
|
||||
}
|
||||
|
||||
static RECIPE = {
|
||||
name: i18n.t("Recipe"),
|
||||
name: "Recipe",
|
||||
apiName: "Recipe",
|
||||
list: {
|
||||
params: ["query", "keywords", "foods", "units", "rating", "books", "keywordsOr", "foodsOr", "booksOr", "internal", "random", "_new", "page", "pageSize", "options"],
|
||||
@@ -439,7 +446,7 @@ export class Models {
|
||||
}
|
||||
|
||||
static USER_NAME = {
|
||||
name: i18n.t("User"),
|
||||
name: "User",
|
||||
apiName: "User",
|
||||
list: {
|
||||
params: ["filter_list"],
|
||||
@@ -447,7 +454,7 @@ export class Models {
|
||||
}
|
||||
|
||||
static MEAL_TYPE = {
|
||||
name: i18n.t("Meal_Type"),
|
||||
name: "Meal_Type",
|
||||
apiName: "MealType",
|
||||
list: {
|
||||
params: ["filter_list"],
|
||||
@@ -455,7 +462,7 @@ export class Models {
|
||||
}
|
||||
|
||||
static MEAL_PLAN = {
|
||||
name: i18n.t("Meal_Plan"),
|
||||
name: "Meal_Plan",
|
||||
apiName: "MealPlan",
|
||||
list: {
|
||||
params: ["options"],
|
||||
@@ -463,7 +470,7 @@ export class Models {
|
||||
}
|
||||
|
||||
static USERFILE = {
|
||||
name: i18n.t("File"),
|
||||
name: "File",
|
||||
apiName: "UserFile",
|
||||
paginated: false,
|
||||
list: {
|
||||
@@ -492,13 +499,13 @@ export class Models {
|
||||
},
|
||||
}
|
||||
static USER = {
|
||||
name: i18n.t("User"),
|
||||
name: "User",
|
||||
apiName: "User",
|
||||
paginated: false,
|
||||
}
|
||||
|
||||
static STEP = {
|
||||
name: i18n.t("Step"),
|
||||
name: "Step",
|
||||
apiName: "Step",
|
||||
list: {
|
||||
params: ["recipe", "query", "page", "pageSize", "options"],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -156,7 +156,7 @@ export function getUserPreference(pref = undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (pref) {
|
||||
return user_preference[pref]
|
||||
return user_preference?.[pref]
|
||||
}
|
||||
return user_preference
|
||||
}
|
||||
@@ -389,6 +389,8 @@ export function getForm(model, action, item1, item2) {
|
||||
}
|
||||
if (value?.form_field) {
|
||||
value["value"] = item1?.[value?.field] ?? undefined
|
||||
value["help"] = item1?.[value?.help_text_field] ?? value?.help_text ?? undefined
|
||||
value["subtitle"] = item1?.[value?.subtitle_field] ?? value?.subtitle ?? undefined
|
||||
form.fields.push({
|
||||
...value,
|
||||
...{
|
||||
|
||||
@@ -17,6 +17,14 @@ const pages = {
|
||||
entry: "./src/apps/ImportResponseView/main.js",
|
||||
chunks: ["chunk-vendors"],
|
||||
},
|
||||
export_response_view: {
|
||||
entry: "./src/apps/ExportResponseView/main.js",
|
||||
chunks: ["chunk-vendors"],
|
||||
},
|
||||
export_view: {
|
||||
entry: "./src/apps/ExportView/main.js",
|
||||
chunks: ["chunk-vendors"],
|
||||
},
|
||||
supermarket_view: {
|
||||
entry: "./src/apps/SupermarketView/main.js",
|
||||
chunks: ["chunk-vendors"],
|
||||
|
||||
@@ -1737,7 +1737,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@vue/cli-plugin-vuex/-/cli-plugin-vuex-4.5.15.tgz#466c1f02777d02fef53a9bb49a36cc3a3bcfec4e"
|
||||
integrity sha512-fqap+4HN+w+InDxlA3hZTOGE0tzBTgXhKLoDydhywqgmhQ1D9JA6Feh94ze6tG8DsWX58/ujYUqA8jAz17FJtg==
|
||||
|
||||
"@vue/cli-service@~4.5.13":
|
||||
"@vue/cli-service@~4.5.15":
|
||||
version "4.5.15"
|
||||
resolved "https://registry.yarnpkg.com/@vue/cli-service/-/cli-service-4.5.15.tgz#0e9a186d51550027d0e68e95042077eb4d115b45"
|
||||
integrity sha512-sFWnLYVCn4zRfu45IcsIE9eXM0YpDV3S11vlM2/DVbIPAGoYo5ySpSof6aHcIvkeGsIsrHFpPHzNvDZ/efs7jA==
|
||||
@@ -1818,47 +1818,47 @@
|
||||
semver "^6.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
"@vue/compiler-core@3.2.26":
|
||||
version "3.2.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.26.tgz#9ab92ae624da51f7b6064f4679c2d4564f437cc8"
|
||||
integrity sha512-N5XNBobZbaASdzY9Lga2D9Lul5vdCIOXvUMd6ThcN8zgqQhPKfCV+wfAJNNJKQkSHudnYRO2gEB+lp0iN3g2Tw==
|
||||
"@vue/compiler-core@3.2.29":
|
||||
version "3.2.29"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.29.tgz#b06097ab8ff0493177c68c5ea5b63d379a061097"
|
||||
integrity sha512-RePZ/J4Ub3sb7atQw6V6Rez+/5LCRHGFlSetT3N4VMrejqJnNPXKUt5AVm/9F5MJriy2w/VudEIvgscCfCWqxw==
|
||||
dependencies:
|
||||
"@babel/parser" "^7.16.4"
|
||||
"@vue/shared" "3.2.26"
|
||||
"@vue/shared" "3.2.29"
|
||||
estree-walker "^2.0.2"
|
||||
source-map "^0.6.1"
|
||||
|
||||
"@vue/compiler-dom@3.2.26":
|
||||
version "3.2.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.26.tgz#c7a7b55d50a7b7981dd44fc28211df1450482667"
|
||||
integrity sha512-smBfaOW6mQDxcT3p9TKT6mE22vjxjJL50GFVJiI0chXYGU/xzC05QRGrW3HHVuJrmLTLx5zBhsZ2dIATERbarg==
|
||||
"@vue/compiler-dom@3.2.29":
|
||||
version "3.2.29"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.29.tgz#ad0ead405bd2f2754161335aad9758aa12430715"
|
||||
integrity sha512-y26vK5khdNS9L3ckvkqJk/78qXwWb75Ci8iYLb67AkJuIgyKhIOcR1E8RIt4mswlVCIeI9gQ+fmtdhaiTAtrBQ==
|
||||
dependencies:
|
||||
"@vue/compiler-core" "3.2.26"
|
||||
"@vue/shared" "3.2.26"
|
||||
"@vue/compiler-core" "3.2.29"
|
||||
"@vue/shared" "3.2.29"
|
||||
|
||||
"@vue/compiler-sfc@^3.2.20":
|
||||
version "3.2.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.26.tgz#3ce76677e4aa58311655a3bea9eb1cb804d2273f"
|
||||
integrity sha512-ePpnfktV90UcLdsDQUh2JdiTuhV0Skv2iYXxfNMOK/F3Q+2BO0AulcVcfoksOpTJGmhhfosWfMyEaEf0UaWpIw==
|
||||
"@vue/compiler-sfc@^3.2.29":
|
||||
version "3.2.29"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.29.tgz#f76d556cd5fca6a55a3ea84c88db1a2a53a36ead"
|
||||
integrity sha512-X9+0dwsag2u6hSOP/XsMYqFti/edvYvxamgBgCcbSYuXx1xLZN+dS/GvQKM4AgGS4djqo0jQvWfIXdfZ2ET68g==
|
||||
dependencies:
|
||||
"@babel/parser" "^7.16.4"
|
||||
"@vue/compiler-core" "3.2.26"
|
||||
"@vue/compiler-dom" "3.2.26"
|
||||
"@vue/compiler-ssr" "3.2.26"
|
||||
"@vue/reactivity-transform" "3.2.26"
|
||||
"@vue/shared" "3.2.26"
|
||||
"@vue/compiler-core" "3.2.29"
|
||||
"@vue/compiler-dom" "3.2.29"
|
||||
"@vue/compiler-ssr" "3.2.29"
|
||||
"@vue/reactivity-transform" "3.2.29"
|
||||
"@vue/shared" "3.2.29"
|
||||
estree-walker "^2.0.2"
|
||||
magic-string "^0.25.7"
|
||||
postcss "^8.1.10"
|
||||
source-map "^0.6.1"
|
||||
|
||||
"@vue/compiler-ssr@3.2.26":
|
||||
version "3.2.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.26.tgz#fd049523341fbf4ab5e88e25eef566d862894ba7"
|
||||
integrity sha512-2mywLX0ODc4Zn8qBoA2PDCsLEZfpUGZcyoFRLSOjyGGK6wDy2/5kyDOWtf0S0UvtoyVq95OTSGIALjZ4k2q/ag==
|
||||
"@vue/compiler-ssr@3.2.29":
|
||||
version "3.2.29"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.29.tgz#37b15b32dcd2f6b410bb61fca3f37b1a92b7eb1e"
|
||||
integrity sha512-LrvQwXlx66uWsB9/VydaaqEpae9xtmlUkeSKF6aPDbzx8M1h7ukxaPjNCAXuFd3fUHblcri8k42lfimHfzMICA==
|
||||
dependencies:
|
||||
"@vue/compiler-dom" "3.2.26"
|
||||
"@vue/shared" "3.2.26"
|
||||
"@vue/compiler-dom" "3.2.29"
|
||||
"@vue/shared" "3.2.29"
|
||||
|
||||
"@vue/component-compiler-utils@^3.1.0", "@vue/component-compiler-utils@^3.1.2":
|
||||
version "3.3.0"
|
||||
@@ -1890,21 +1890,21 @@
|
||||
resolved "https://registry.yarnpkg.com/@vue/preload-webpack-plugin/-/preload-webpack-plugin-1.1.2.tgz#ceb924b4ecb3b9c43871c7a429a02f8423e621ab"
|
||||
integrity sha512-LIZMuJk38pk9U9Ur4YzHjlIyMuxPlACdBIHH9/nGYVTsaGKOSnSuELiE8vS9wa+dJpIYspYUOqk+L1Q4pgHQHQ==
|
||||
|
||||
"@vue/reactivity-transform@3.2.26":
|
||||
version "3.2.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.26.tgz#6d8f20a4aa2d19728f25de99962addbe7c4d03e9"
|
||||
integrity sha512-XKMyuCmzNA7nvFlYhdKwD78rcnmPb7q46uoR00zkX6yZrUmcCQ5OikiwUEVbvNhL5hBJuvbSO95jB5zkUon+eQ==
|
||||
"@vue/reactivity-transform@3.2.29":
|
||||
version "3.2.29"
|
||||
resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.29.tgz#a08d606e10016b7cf588d1a43dae4db2953f9354"
|
||||
integrity sha512-YF6HdOuhdOw6KyRm59+3rML8USb9o8mYM1q+SH0G41K3/q/G7uhPnHGKvspzceD7h9J3VR1waOQ93CUZj7J7OA==
|
||||
dependencies:
|
||||
"@babel/parser" "^7.16.4"
|
||||
"@vue/compiler-core" "3.2.26"
|
||||
"@vue/shared" "3.2.26"
|
||||
"@vue/compiler-core" "3.2.29"
|
||||
"@vue/shared" "3.2.29"
|
||||
estree-walker "^2.0.2"
|
||||
magic-string "^0.25.7"
|
||||
|
||||
"@vue/shared@3.2.26":
|
||||
version "3.2.26"
|
||||
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.26.tgz#7acd1621783571b9a82eca1f041b4a0a983481d9"
|
||||
integrity sha512-vPV6Cq+NIWbH5pZu+V+2QHE9y1qfuTq49uNWw4f7FDEeZaDU2H2cx5jcUZOAKW7qTrUS4k6qZPbMy1x4N96nbA==
|
||||
"@vue/shared@3.2.29":
|
||||
version "3.2.29"
|
||||
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.29.tgz#07dac7051117236431d2f737d16932aa38bbb925"
|
||||
integrity sha512-BjNpU8OK6Z0LVzGUppEk0CMYm/hKDnZfYdjSmPOs0N+TR1cLKJAkDwW8ASZUvaaSLEi6d3hVM7jnWnX+6yWnHw==
|
||||
|
||||
"@vue/web-component-wrapper@^1.2.0":
|
||||
version "1.3.0"
|
||||
@@ -3447,10 +3447,10 @@ core-js@^2.4.0, core-js@^2.5.0:
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
|
||||
integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
|
||||
|
||||
core-js@^3.20.2, core-js@^3.6.0, core-js@^3.6.5, core-js@^3.7.0, core-js@^3.8.3:
|
||||
version "3.20.2"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.20.2.tgz#46468d8601eafc8b266bd2dd6bf9dee622779581"
|
||||
integrity sha512-nuqhq11DcOAbFBV4zCbKeGbKQsUDRqTX0oqx7AttUBuqe3h20ixsE039QHelbL6P4h+9kytVqyEtyZ6gsiwEYw==
|
||||
core-js@^3.20.3, core-js@^3.6.0, core-js@^3.6.5, core-js@^3.7.0, core-js@^3.8.3:
|
||||
version "3.20.3"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.20.3.tgz#c710d0a676e684522f3db4ee84e5e18a9d11d69a"
|
||||
integrity sha512-vVl8j8ph6tRS3B8qir40H7yw7voy17xL0piAjlbBUsH7WIfzoedL/ZOr1OV9FyZQLWXsayOJyV4tnRyXR85/ag==
|
||||
|
||||
core-util-is@1.0.2:
|
||||
version "1.0.2"
|
||||
@@ -10251,10 +10251,10 @@ typedarray@^0.0.6:
|
||||
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
|
||||
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
|
||||
|
||||
typescript@~4.5.2:
|
||||
version "4.5.4"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.4.tgz#a17d3a0263bf5c8723b9c52f43c5084edf13c2e8"
|
||||
integrity sha512-VgYs2A2QIRuGphtzFV7aQJduJ2gyfTljngLzjpfW9FoYZF6xuw1W0vW9ghCKLfcWrCFxK81CSGRAvS1pn4fIUg==
|
||||
typescript@~4.5.5:
|
||||
version "4.5.5"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3"
|
||||
integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==
|
||||
|
||||
uc.micro@^1.0.1, uc.micro@^1.0.5:
|
||||
version "1.0.6"
|
||||
|
||||
Reference in New Issue
Block a user