Merge remote-tracking branch 'origin/develop' into HomeAssistantConnector

# Conflicts:
#	cookbook/views/api.py
#	recipes/settings.py
This commit is contained in:
Mikhail Epifanov 2024-02-17 00:29:29 +05:30
commit beb860acc6
No known key found for this signature in database
52 changed files with 3017 additions and 2437 deletions

View File

@ -1,6 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="vaben">
<words>
<w>mealplan</w>
<w>pinia</w>
<w>selfhosted</w>
<w>unapplied</w>

View File

@ -1,5 +1,6 @@
from datetime import datetime
from allauth.account.forms import ResetPasswordForm, SignupForm
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
@ -9,6 +10,8 @@ from django_scopes import scopes_disabled
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
from hcaptcha.fields import hCaptchaField
from .models import (Comment, Food, InviteLink, Keyword, Recipe, RecipeBook, RecipeBookEntry,
SearchPreference, Space, Storage, Sync, User, UserPreference, ConnectorConfig)
@ -358,12 +361,12 @@ class SpaceJoinForm(forms.Form):
token = forms.CharField()
class AllAuthSignupForm(forms.Form):
class AllAuthSignupForm(SignupForm):
captcha = hCaptchaField()
terms = forms.BooleanField(label=_('Accept Terms and Privacy'))
def __init__(self, **kwargs):
super(AllAuthSignupForm, self).__init__(**kwargs)
super().__init__(**kwargs)
if settings.PRIVACY_URL == '' and settings.TERMS_URL == '':
self.fields.pop('terms')
if settings.HCAPTCHA_SECRET == '':
@ -373,6 +376,15 @@ class AllAuthSignupForm(forms.Form):
pass
class CustomPasswordResetForm(ResetPasswordForm):
captcha = hCaptchaField()
def __init__(self, **kwargs):
super(CustomPasswordResetForm, self).__init__(**kwargs)
if settings.HCAPTCHA_SECRET == '':
self.fields.pop('captcha')
class UserCreateForm(forms.Form):
name = forms.CharField(label='Username')
password = forms.CharField(

View File

@ -98,7 +98,7 @@ class AutomationEngine:
try:
return self.food_aliases[food.lower()]
except KeyError:
return food
return self.apply_regex_replace_automation(food, Automation.FOOD_REPLACE)
else:
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1__iexact=food, disabled=False).order_by('order').first():
return automation.param_2

View File

@ -27,9 +27,6 @@ def shopping_helper(qs, request):
elif checked in ['true', 1, '1']:
qs = qs.filter(checked=True)
elif checked in ['recent']:
today_start = timezone.now().replace(hour=0, minute=0, second=0)
week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days)
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
supermarket_order = ['checked'] + supermarket_order
return qs.distinct().order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')

View File

@ -15,8 +15,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-11-22 18:19+0000\n"
"Last-Translator: Spreez <tandoor@larsdev.de>\n"
"PO-Revision-Date: 2024-02-13 16:19+0000\n"
"Last-Translator: Kirstin Seidel-Gebert <kirstin@trebeg.de>\n"
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/de/>\n"
"Language: de\n"
@ -161,7 +161,7 @@ msgstr "Name"
#: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88
msgid "Keywords"
msgstr "Schlüsselwörter"
msgstr "Schlagwörter"
#: .\cookbook\forms.py:125
msgid "Preparation time in minutes"
@ -1436,9 +1436,9 @@ msgid ""
" "
msgstr ""
"\n"
" <b>Passwort und Token</b> werden im <b>Klartext</b> in der Datenbank "
" <b>Kennwort und Token</b> werden im <b>Klartext</b> in der Datenbank "
"gespeichert.\n"
" Dies ist notwendig da Passwort oder Token benötigt werden, um API-"
" Dies ist notwendig da Kennwort oder Token benötigt werden, um API-"
"Anfragen zu stellen, bringt jedoch auch ein Sicherheitsrisiko mit sich. <br/>"
"\n"
" Um das Risiko zu minimieren sollten, wenn möglich, Tokens oder "

View File

@ -13,8 +13,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-08-15 19:19+0000\n"
"Last-Translator: Jochum van der Heide <jochum@famvanderheide.com>\n"
"PO-Revision-Date: 2024-02-10 12:20+0000\n"
"Last-Translator: Jonan B <jonanb@pm.me>\n"
"Language-Team: Dutch <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/nl/>\n"
"Language: nl\n"
@ -159,7 +159,7 @@ msgstr "Naam"
#: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88
msgid "Keywords"
msgstr "Etiketten"
msgstr "Trefwoorden"
#: .\cookbook\forms.py:125
msgid "Preparation time in minutes"
@ -1224,7 +1224,7 @@ msgstr "Markdown gids"
#: .\cookbook\templates\base.html:329
msgid "GitHub"
msgstr "GitHub"
msgstr "Github"
#: .\cookbook\templates\base.html:331
msgid "Translate Tandoor"

View File

@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-02-26 13:15+0000\n"
"Last-Translator: 吕楪 <thy@irithys.com>\n"
"PO-Revision-Date: 2024-02-15 03:19+0000\n"
"Last-Translator: dalan <xzdlj@outlook.com>\n"
"Language-Team: Chinese (Simplified) <http://translate.tandoor.dev/projects/"
"tandoor/recipes-backend/zh_Hans/>\n"
"Language: zh_CN\n"
@ -480,34 +480,32 @@ msgid "One of queryset or hash_key must be provided"
msgstr "必须提供 queryset 或 hash_key 之一"
#: .\cookbook\helper\recipe_url_import.py:266
#, fuzzy
#| msgid "Use fractions"
msgid "reverse rotation"
msgstr "使用分数"
msgstr "反向旋转"
#: .\cookbook\helper\recipe_url_import.py:267
msgid "careful rotation"
msgstr ""
msgstr "小心旋转"
#: .\cookbook\helper\recipe_url_import.py:268
msgid "knead"
msgstr ""
msgstr ""
#: .\cookbook\helper\recipe_url_import.py:269
msgid "thicken"
msgstr ""
msgstr "增稠"
#: .\cookbook\helper\recipe_url_import.py:270
msgid "warm up"
msgstr ""
msgstr "预热"
#: .\cookbook\helper\recipe_url_import.py:271
msgid "ferment"
msgstr ""
msgstr "发酵"
#: .\cookbook\helper\recipe_url_import.py:272
msgid "sous-vide"
msgstr ""
msgstr "真空烹调法"
#: .\cookbook\helper\shopping_helper.py:157
msgid "You must supply a servings size"
@ -549,10 +547,8 @@ msgid "Imported %s recipes."
msgstr "导入了%s菜谱。"
#: .\cookbook\integration\openeats.py:26
#, fuzzy
#| msgid "Recipe Home"
msgid "Recipe source:"
msgstr "菜谱主页"
msgstr "菜谱来源:"
#: .\cookbook\integration\paprika.py:49
msgid "Notes"

View File

@ -6,11 +6,12 @@ from django.conf import settings
from django.db import migrations, models
from django_scopes import scopes_disabled
from cookbook.models import PermissionModelMixin, ShoppingListEntry
from cookbook.models import PermissionModelMixin
def copy_values_to_sle(apps, schema_editor):
with scopes_disabled():
ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry')
entries = ShoppingListEntry.objects.all()
for entry in entries:
if entry.shoppinglist_set.first():

View File

@ -1,25 +1,22 @@
# Generated by Django 3.2.7 on 2021-10-01 22:34
import datetime
from datetime import timedelta
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db import migrations
from django.utils import timezone
from django.utils.timezone import utc
from django_scopes import scopes_disabled
from cookbook.models import FoodInheritField, ShoppingListEntry
def delete_orphaned_sle(apps, schema_editor):
ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry')
with scopes_disabled():
# shopping list entry is orphaned - delete it
ShoppingListEntry.objects.filter(shoppinglist=None).delete()
def create_inheritfields(apps, schema_editor):
FoodInheritField = apps.get_model('cookbook', 'FoodInheritField')
FoodInheritField.objects.create(name='Supermarket Category', field='supermarket_category')
FoodInheritField.objects.create(name='On Hand', field='food_onhand')
FoodInheritField.objects.create(name='Diet', field='diet')
@ -29,6 +26,7 @@ def create_inheritfields(apps, schema_editor):
def set_completed_at(apps, schema_editor):
ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry')
today_start = timezone.now().replace(hour=0, minute=0, second=0)
# arbitrary - keeping all of the closed shopping list items out of the 'recent' view
month_ago = today_start - timedelta(days=30)

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-01-28 10:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0209_remove_space_use_plural'),
]
operations = [
migrations.AddField(
model_name='shoppinglistentry',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@ -789,7 +789,7 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
objects = ScopedManager(space='space')
def __str__(self):
return f'{self.pk}: {self.amount} {self.food.name} {self.unit.name}'
return f'{self.pk}: {self.amount} ' + (self.food.name if self.food else ' ') + (self.unit.name if self.unit else '')
class Meta:
ordering = ['order', 'pk']
@ -1009,6 +1009,8 @@ class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionMod
shared = models.ManyToManyField(User, blank=True, related_name='shared_with')
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
filter = models.ForeignKey('cookbook.CustomFilter', null=True, blank=True, on_delete=models.SET_NULL)
order = models.IntegerField(default=0)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
@ -1125,6 +1127,8 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
checked = models.BooleanField(default=False)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
completed_at = models.DateTimeField(null=True, blank=True)
delay_until = models.DateTimeField(null=True, blank=True)

View File

@ -57,7 +57,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
api_serializer = None
# extended values are computationally expensive and not needed in normal circumstances
try:
if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
if str2bool(
self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
return fields
except (AttributeError, KeyError):
pass
@ -81,12 +82,14 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
class OpenDataModelMixin(serializers.ModelSerializer):
def create(self, validated_data):
if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data['open_data_slug'].strip() == '':
if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data[
'open_data_slug'].strip() == '':
validated_data['open_data_slug'] = None
return super().create(validated_data)
def update(self, instance, validated_data):
if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data['open_data_slug'].strip() == '':
if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data[
'open_data_slug'].strip() == '':
validated_data['open_data_slug'] = None
return super().update(instance, validated_data)
@ -122,7 +125,8 @@ class CustomOnHandField(serializers.Field):
if not self.context["request"].user.is_authenticated:
return []
shared_users = []
if c := caches['default'].get(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
if c := caches['default'].get(
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
shared_users = c
else:
try:
@ -332,7 +336,8 @@ class UserSpaceSerializer(WritableNestedModelSerializer):
class Meta:
model = UserSpace
fields = ('id', 'user', 'space', 'groups', 'active', 'internal_note', 'invite_link', 'created_at', 'updated_at',)
fields = (
'id', 'user', 'space', 'groups', 'active', 'internal_note', 'invite_link', 'created_at', 'updated_at',)
read_only_fields = ('id', 'invite_link', 'created_at', 'updated_at', 'space')
@ -382,13 +387,15 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
class Meta:
model = UserPreference
fields = (
'user', 'image', 'theme', 'nav_bg_color', 'nav_text_color', 'nav_show_logo', 'default_unit', 'default_page', 'use_fractions', 'use_kj',
'user', 'image', 'theme', 'nav_bg_color', 'nav_text_color', 'nav_show_logo', 'default_unit', 'default_page',
'use_fractions', 'use_kj',
'plan_share', 'nav_sticky',
'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', 'show_step_ingredients', 'food_children_exist'
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'show_step_ingredients',
'food_children_exist'
)
@ -498,10 +505,13 @@ class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin, OpenDataModelMixin)
if x := validated_data.get('name', None):
validated_data['plural_name'] = x.strip()
if unit := Unit.objects.filter(Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']), space=space).first():
if unit := Unit.objects.filter(
Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']),
space=space).first():
return unit
obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space,
defaults=validated_data)
return obj
def update(self, instance, validated_data):
@ -521,7 +531,8 @@ class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerial
def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip()
space = validated_data.pop('space', self.context['request'].space)
obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space,
defaults=validated_data)
return obj
def update(self, instance, validated_data):
@ -546,7 +557,8 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer, OpenDataMo
def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip()
space = validated_data.pop('space', self.context['request'].space)
obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space,
defaults=validated_data)
return obj
class Meta:
@ -561,7 +573,8 @@ class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer,
def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip()
space = validated_data.pop('space', self.context['request'].space)
obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space,
defaults=validated_data)
return obj
class Meta:
@ -686,12 +699,14 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
properties = validated_data.pop('properties', None)
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, properties_food_unit=properties_food_unit,
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space,
properties_food_unit=properties_food_unit,
defaults=validated_data)
if properties and len(properties) > 0:
for p in properties:
obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'], property_amount=p['property_amount'], space=space))
obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'],
property_amount=p['property_amount'], space=space))
return obj
@ -723,7 +738,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
'properties', 'properties_food_amount', 'properties_food_unit', 'fdc_id',
'food_onhand', 'supermarket_category',
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping',
'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields', 'open_data_slug',
'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields',
'open_data_slug',
)
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
@ -747,7 +763,8 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer):
uch = UnitConversionHelper(self.context['request'].space)
conversions = []
for c in uch.get_conversions(obj):
conversions.append({'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper
conversions.append(
{'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper
return conversions
else:
return []
@ -849,7 +866,8 @@ class UnitConversionSerializer(WritableNestedModelSerializer, OpenDataModelMixin
class Meta:
model = UnitConversion
fields = ('id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug')
fields = (
'id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug')
class NutritionInformationSerializer(serializers.ModelSerializer):
@ -919,7 +937,8 @@ class RecipeSerializer(RecipeBaseSerializer):
fields = (
'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time',
'waiting_time', 'created_by', 'created_at', 'updated_at', 'source_url',
'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings', 'file_path', 'servings_text', 'rating',
'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings',
'file_path', 'servings_text', 'rating',
'last_cooked',
'private', 'shared',
)
@ -981,7 +1000,7 @@ class RecipeBookSerializer(SpacedModelSerializer, WritableNestedModelSerializer)
class Meta:
model = RecipeBook
fields = ('id', 'name', 'description', 'shared', 'created_by', 'filter')
fields = ('id', 'name', 'description', 'shared', 'created_by', 'filter', 'order')
read_only_fields = ('created_by',)
@ -998,7 +1017,8 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer):
def create(self, validated_data):
book = validated_data['book']
recipe = validated_data['recipe']
if not book.get_owner() == self.context['request'].user and not self.context['request'].user in book.get_shared():
if not book.get_owner() == self.context['request'].user and not self.context[
'request'].user in book.get_shared():
raise NotFound(detail=None, code=None)
obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe)
return obj
@ -1062,6 +1082,8 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
name = serializers.SerializerMethodField('get_name') # should this be done at the front end?
recipe_name = serializers.ReadOnlyField(source='recipe.name')
mealplan_note = serializers.ReadOnlyField(source='mealplan.note')
mealplan_from_date = serializers.ReadOnlyField(source='mealplan.from_date')
mealplan_type = serializers.ReadOnlyField(source='mealplan.meal_type.name')
servings = CustomDecimalField()
def get_name(self, obj):
@ -1085,14 +1107,14 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
class Meta:
model = ShoppingListRecipe
fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note')
fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note', 'mealplan_from_date',
'mealplan_type')
read_only_fields = ('id',)
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
food = FoodSerializer(allow_null=True)
unit = UnitSerializer(allow_null=True, required=False)
ingredient_note = serializers.ReadOnlyField(source='ingredient.note')
recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True)
amount = CustomDecimalField()
created_by = UserSerializer(read_only=True)
@ -1103,7 +1125,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
# autosync values are only needed for frequent 'checked' value updating
if self.context['request'] and bool(int(self.context['request'].query_params.get('autosync', False))):
for f in list(set(fields) - set(['id', 'checked'])):
for f in list(set(fields) - set(['id', 'checked', 'updated_at', ])):
del fields[f]
return fields
@ -1145,11 +1167,16 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
class Meta:
model = ShoppingListEntry
fields = (
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked',
'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked',
'recipe_mealplan',
'created_by', 'created_at', 'completed_at', 'delay_until'
'created_by', 'created_at', 'updated_at', 'completed_at', 'delay_until'
)
read_only_fields = ('id', 'created_by', 'created_at',)
read_only_fields = ('id', 'created_by', 'created_at','updated_at',)
class ShoppingListEntryBulkSerializer(serializers.Serializer):
ids = serializers.ListField()
checked = serializers.BooleanField()
# TODO deprecate
@ -1304,7 +1331,8 @@ class InviteLinkSerializer(WritableNestedModelSerializer):
class Meta:
model = InviteLink
fields = (
'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by', 'created_at',)
'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by',
'created_at',)
read_only_fields = ('id', 'uuid', 'created_by', 'created_at',)

View File

@ -480,7 +480,7 @@ hr {
margin-top: 1rem;
margin-bottom: 1rem;
border: 0;
border-top: 1px solid rgba(0, 0, 0, .1)
border-top: 1px solid #ced4da;
}
.small, small {

View File

@ -2850,89 +2850,41 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
color: #fff
}
.btn-primary:hover {
background: transparent;
color: #b98766;
border: 1px solid #b98766
}
.btn-secondary {
transition: all .5s ease-in-out;
color: #fff
}
.btn-secondary:hover {
background: transparent;
color: #b55e4f;
border: 1px solid #b55e4f
}
.btn-success {
transition: all .5s ease-in-out;
color: #fff
}
.btn-success:hover {
background: transparent;
color: #82aa8b;
border: 1px solid #82aa8b
}
.btn-info {
transition: all .5s ease-in-out;
color: #fff
}
.btn-info:hover {
background: transparent;
color: #385f84;
border: 1px solid #385f84
}
.btn-warning {
transition: all .5s ease-in-out;
color: #fff
}
.btn-warning:hover {
background: transparent;
color: #eaaa21;
border: 1px solid #eaaa21
}
.btn-danger {
transition: all .5s ease-in-out;
color: #fff
}
.btn-danger:hover {
background: transparent;
color: #a7240e;
border: 1px solid #a7240e
}
.btn-light {
transition: all .5s ease-in-out;
color: #fff
}
.btn-light:hover {
background-color: hsla(0, 0%, 18%, .5);
color: #cfd5cd;
border: 1px solid hsla(0, 0%, 18%, .5)
}
.btn-dark {
transition: all .5s ease-in-out;
color: #fff
}
.btn-dark:hover {
background: transparent;
color: #221e1e;
border: 1px solid #221e1e
}
.btn-opacity-primary {
color: #b98766;
background-color: #0012a7;
@ -6155,7 +6107,7 @@ a.close.disabled {
padding: .5rem .75rem;
margin-bottom: 0;
font-size: 1rem;
background-color: #f7f7f7;
background-color: #242424;
border-bottom: 1px solid #ebebeb;
border-top-left-radius: calc(.3rem - 1px);
border-top-right-radius: calc(.3rem - 1px)

View File

@ -34,5 +34,14 @@
</div>
</div>
<div class="row mt-3">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3 text-center">
<a href="{% url 'account_login' %}">{% trans "Sign In" %}</a>
{% if SIGNUP_ENABLED %}
- <a href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -7,11 +7,32 @@
{% block title %}{% trans "Password Reset" %}{% endblock %}
{% block content %}
<h3>{% trans "Password Reset" %}</h3>
{% if user.is_authenticated %}
{% include "account/snippets/already_logged_in.html" %}
{% endif %}
<div class="row">
<div class="col-12" style="text-align: center">
<h3>{% trans "Password Reset" %}</h3>
</div>
</div>
<div class="row">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<hr>
<p>{% blocktrans %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}</p>
</div>
</div>
<div class="row mt-3">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3 text-center">
<a href="{% url 'account_login' %}">{% trans "Sign In" %}</a>
{% if SIGNUP_ENABLED %}
- <a href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -408,7 +408,7 @@
</div>
{% endif %}
<div class="container-fluid mt-2 mt-md-5 mt-xl-5 mt-lg-5{% if request.user.userpreference.left_handed %} left-handed {% endif %}"
<div class="container-fluid mt-2 mt-md-3 mt-xl-3 mt-lg-3{% if request.user.userpreference.left_handed %} left-handed {% endif %}"
id="id_base_container">
<div class="row">
<div class="col-xl-2 d-none d-xl-block">

View File

@ -79,6 +79,7 @@
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
window.RECIPE_ID = {{recipe.pk}};
window.RECIPE_SERVINGS = '{{ servings }}'
window.SHARE_UID = '{{ share }}';
window.USER_PREF = {
'use_fractions': {% if request.user.userpreference.use_fractions %} true {% else %} false {% endif %},

View File

@ -2,6 +2,7 @@
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% block title %} {{ title }} {% endblock %}
{% block content_fluid %}
@ -10,7 +11,10 @@
<shopping-list-view></shopping-list-view>
</div>
{% endblock %} {% block script %} {% if debug %}
{% endblock %}
{% block script %}
{% if debug %}
<script src="{% url 'js_reverse' %}"></script>
{% else %}
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
@ -22,4 +26,6 @@
window.SHOPPING_MIN_AUTOSYNC_INTERVAL = {{ SHOPPING_MIN_AUTOSYNC_INTERVAL }}
</script>
{% render_bundle 'shopping_list_view' %} {% endblock %}
{% render_bundle 'shopping_list_view' %}
{% endblock %}

View File

@ -13,7 +13,9 @@ DETAIL_URL = 'api:shoppinglistentry-detail'
@pytest.fixture()
def obj_1(space_1, u1_s1):
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 1', space=space_1)[0], space=space_1)
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1),
food=Food.objects.get_or_create(name='test 1', space=space_1)[0],
space=space_1)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e)
return e
@ -21,7 +23,9 @@ def obj_1(space_1, u1_s1):
@pytest.fixture
def obj_2(space_1, u1_s1):
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 2', space=space_1)[0], space=space_1)
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1),
food=Food.objects.get_or_create(name='test 2', space=space_1)[0],
space=space_1)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e)
return e
@ -79,6 +83,29 @@ def test_update(arg, request, obj_1):
assert response['amount'] == 2
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 403],
['u1_s1', 200],
['a1_s1', 200],
['g1_s2', 403],
['u1_s2', 200],
['a1_s2', 200],
])
def test_bulk_update(arg, request, obj_1, obj_2):
c = request.getfixturevalue(arg[0])
r = c.post(
reverse(LIST_URL, ) + 'bulk/',
{'ids': [obj_1.id, obj_2.id], 'checked': True},
content_type='application/json'
)
assert r.status_code == arg[1]
assert r
if r.status_code == 200:
obj_1.refresh_from_db()
assert obj_1.checked == (arg[0] == 'u1_s1')
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 201],

View File

@ -214,6 +214,9 @@ def test_completed(sle, u1_s1):
def test_recent(sle, u1_s1):
user = auth.get_user(u1_s1)
user.userpreference.shopping_recent_days = 7 # hardcoded API limit 14 days
user.userpreference.save()
today_start = timezone.now().replace(hour=0, minute=0, second=0)
# past_date within recent_days threshold

View File

@ -7,7 +7,7 @@ from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import Food, Ingredient
from cookbook.models import Food, Ingredient, ShoppingListRecipe, ShoppingListEntry
from cookbook.tests.factories import MealPlanFactory, RecipeFactory, StepFactory, UserFactory
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql':
@ -126,7 +126,7 @@ def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert [x['created_by']['id'] for x in r].count(user.id) == sle_count
all_ing = [x['ingredient'] for x in r]
all_ing = list(ShoppingListEntry.objects.filter(list_recipe__recipe=recipe).all().values_list('ingredient', flat=True))
keep_ing = all_ing[1:-1] # remove first and last element
del keep_ing[int(len(keep_ing) / 2)] # remove a middle element
list_recipe = r[0]['list_recipe']

View File

@ -30,6 +30,7 @@ from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import make_aware
from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
from icalendar import Calendar, Event
@ -102,7 +103,8 @@ from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer,
SupermarketCategorySerializer, SupermarketSerializer,
SyncLogSerializer, SyncSerializer, UnitConversionSerializer,
UnitSerializer, UserFileSerializer, UserPreferenceSerializer,
UserSerializer, UserSpaceSerializer, ViewLogSerializer, ConnectorConfigConfigSerializer)
UserSerializer, UserSpaceSerializer, ViewLogSerializer,
ShoppingListEntryBulkSerializer, ConnectorConfigConfigSerializer)
from cookbook.views.import_export import get_integration
from recipes import settings
from recipes.settings import FDC_API_KEY, DRF_THROTTLE_RECIPE_URL_IMPORT
@ -489,6 +491,7 @@ class SyncLogViewSet(viewsets.ReadOnlyModelViewSet):
class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin):
schema = FilterSchema()
queryset = Supermarket.objects
serializer_class = SupermarketSerializer
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
@ -498,7 +501,7 @@ class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin):
return super().get_queryset()
class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin):
class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin, MergeMixin):
queryset = SupermarketCategory.objects
model = SupermarketCategory
serializer_class = SupermarketCategorySerializer
@ -668,8 +671,16 @@ class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin):
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
def get_queryset(self):
order_field = self.request.GET.get('order_field')
order_direction = self.request.GET.get('order_direction')
if not order_field:
order_field = 'id'
ordering = f"{'' if order_direction == 'asc' else '-'}{order_field}"
self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
space=self.request.space).distinct()
space=self.request.space).distinct().order_by(ordering)
return super().get_queryset()
@ -1169,11 +1180,47 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
if pk := self.request.query_params.getlist('id', []):
self.queryset = self.queryset.filter(food__id__in=[int(i) for i in pk])
if 'checked' in self.request.query_params or 'recent' in self.request.query_params:
if 'checked' in self.request.query_params:
return shopping_helper(self.queryset, self.request)
elif not self.detail:
today_start = timezone.now().replace(hour=0, minute=0, second=0)
week_ago = today_start - datetime.timedelta(days=min(self.request.user.userpreference.shopping_recent_days, 14))
self.queryset = self.queryset.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
try:
last_autosync = self.request.query_params.get('last_autosync', None)
if last_autosync:
last_autosync = datetime.datetime.fromtimestamp(int(last_autosync) / 1000, datetime.timezone.utc)
self.queryset = self.queryset.filter(updated_at__gte=last_autosync)
except:
traceback.print_exc()
# TODO once old shopping list is removed this needs updated to sharing users in preferences
if self.detail:
return self.queryset
else:
return self.queryset[:1000]
@decorators.action(
detail=False,
methods=['POST'],
serializer_class=ShoppingListEntryBulkSerializer,
permission_classes=[CustomIsUser]
)
def bulk(self, request):
serializer = self.serializer_class(data=request.data)
if serializer.is_valid():
ShoppingListEntry.objects.filter(
Q(created_by=self.request.user)
| Q(shoppinglist__shared=self.request.user)
| Q(created_by__in=list(self.request.user.get_shopping_share()))
).filter(space=request.space, id__in=serializer.validated_data['ids']).update(
checked=serializer.validated_data['checked'],
updated_at=timezone.now(),
)
return Response(serializer.data)
else:
return Response(serializer.errors, 400)
# TODO deprecate
@ -1183,11 +1230,13 @@ class ShoppingListViewSet(viewsets.ModelViewSet):
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
def get_queryset(self):
return self.queryset.filter(
self.queryset = self.queryset.filter(
Q(created_by=self.request.user)
| Q(shared=self.request.user)
| Q(created_by__in=list(self.request.user.get_shopping_share()))
).filter(space=self.request.space).distinct()
).filter(space=self.request.space)
return self.queryset.distinct()
def get_serializer_class(self):
try:
@ -1256,6 +1305,7 @@ class BookmarkletImportViewSet(viewsets.ModelViewSet):
class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin):
schema = FilterSchema()
queryset = UserFile.objects
serializer_class = UserFileSerializer
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]

View File

@ -171,9 +171,10 @@ def recipe_view(request, pk, share=None):
created_at__gt=(timezone.now() - timezone.timedelta(minutes=5)),
space=request.space).exists():
ViewLog.objects.create(recipe=recipe, created_by=request.user, space=request.space)
if request.method == "GET":
servings = request.GET.get("servings")
return render(request, 'recipe_view.html',
{'recipe': recipe, 'comments': comments, 'comment_form': comment_form, 'share': share, })
{'recipe': recipe, 'comments': comments, 'comment_form': comment_form, 'share': share, 'servings': servings })
@group_required('user')

View File

@ -65,8 +65,9 @@ At Keycloak, create a new client and assign a `Client-ID`, this client comes wit
To enable Keycloak as a sign in option, set those variables to define the social provider and specify its configuration:
```ini
SOCIAL_PROVIDERS=allauth.socialaccount.providers.keycloak
SOCIALACCOUNT_PROVIDERS='{ "keycloak": { "KEYCLOAK_URL": "https://auth.example.com/", "KEYCLOAK_REALM": "master" } }'
SOCIAL_PROVIDERS=allauth.socialaccount.providers.openid_connect
SOCIALACCOUNT_PROVIDERS='{"openid_connect":{"APPS":[{"provider_id":"keycloak","name":"Keycloak","client_id":"KEYCLOAK_CLIENT_ID","secret":"KEYCLOAK_CLIENT_SECRET","settings":{"server_url":"https://auth.example.org/realms/KEYCLOAK_REALM/.well-known/openid-configuration"}}]}}
'
```
1. Restart the service, login as superuser and open the `Admin` page.

View File

@ -53,7 +53,7 @@ docker stop {{database_container}} {{tandoor_container}}
4. Rename the tandoor volume
``` bash
sudo mv -R ~/.docker/compose/postgres ~/.docker/compose/postgres.old
sudo mv ~/.docker/compose/postgres ~/.docker/compose/postgres.old
```
5. Update image tag on postgres container.

View File

@ -98,8 +98,6 @@ FDC_API_KEY = os.getenv('FDC_API_KEY', 'DEMO_KEY')
SHARING_ABUSE = bool(int(os.getenv('SHARING_ABUSE', False)))
SHARING_LIMIT = int(os.getenv('SHARING_LIMIT', 0))
ACCOUNT_SIGNUP_FORM_CLASS = 'cookbook.forms.AllAuthSignupForm'
DRF_THROTTLE_RECIPE_URL_IMPORT = os.getenv('DRF_THROTTLE_RECIPE_URL_IMPORT', '60/hour')
TERMS_URL = os.getenv('TERMS_URL', '')
@ -556,6 +554,21 @@ 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
# ACCOUNT_SIGNUP_FORM_CLASS = 'cookbook.forms.AllAuthSignupForm'
ACCOUNT_FORMS = {
'signup': 'cookbook.forms.AllAuthSignupForm',
'reset_password': 'cookbook.forms.CustomPasswordResetForm'
}
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
ACCOUNT_RATE_LIMITS = {
"change_password": "1/m/user",
"reset_password": "1/m/ip,1/m/key",
"reset_password_from_key": "1/m/ip",
"signup": "5/m/ip",
"login": "5/m/ip",
}
DISABLE_EXTERNAL_CONNECTORS = bool(int(os.getenv('DISABLE_EXTERNAL_CONNECTORS', False)))
EXTERNAL_CONNECTORS_QUEUE_SIZE = int(os.getenv('EXTERNAL_CONNECTORS_QUEUE_SIZE', 100))

View File

@ -1,11 +1,11 @@
Django==4.2.7
cryptography===41.0.7
Django==4.2.10
cryptography===42.0.0
django-annoying==0.10.6
django-autocomplete-light==3.9.7
django-cleanup==8.0.0
django-crispy-forms==2.0
crispy-bootstrap4==2022.1
django-tables2==2.5.3
django-tables2==2.7.0
djangorestframework==3.14.0
drf-writable-nested==0.7.0
django-oauth-toolkit==2.3.0
@ -30,7 +30,7 @@ mock==5.1.0
Jinja2==3.1.3
django-webpack-loader==1.8.1
git+https://github.com/BITSOLVER/django-js-reverse@071e304fd600107bc64bbde6f2491f1fe049ec82
django-allauth==0.58.1
django-allauth==0.61.1
recipe-scrapers==14.52.0
django-scopes==2.0.0
pytest==7.4.3
@ -42,8 +42,8 @@ django-storages==1.14.2
boto3==1.28.75
django-prometheus==2.2.0
django-hCaptcha==0.2.0
python-ldap==3.4.3
django-auth-ldap==4.4.0
python-ldap==3.4.4
django-auth-ldap==4.6.0
pytest-factoryboy==2.6.0
pyppeteer==1.0.2
validators==0.20.0

View File

@ -13,21 +13,20 @@
"@codemirror/commands": "^6.3.2",
"@codemirror/lang-markdown": "^6.2.3",
"@codemirror/state": "^6.3.3",
"@codemirror/view": "^6.22.2",
"@codemirror/view": "^6.23.1",
"@popperjs/core": "^2.11.7",
"@vue/cli": "^5.0.8",
"@vue/composition-api": "1.7.1",
"axios": "^1.6.0",
"axios": "^1.6.7",
"babel": "^6.23.0",
"babel-core": "^6.26.3",
"babel-loader": "^9.1.0",
"bootstrap-vue": "^2.23.1",
"core-js": "^3.29.1",
"html2pdf.js": "^0.10.1",
"lodash": "^4.17.21",
"mavon-editor": "^2.10.4",
"moment": "^2.29.4",
"pinia": "^2.0.30",
"pinia": "^2.1.7",
"prismjs": "^1.29.0",
"string-similarity": "^4.0.4",
"vue": "^2.6.14",
@ -62,9 +61,9 @@
"babel-eslint": "^10.1.0",
"eslint": "^8.46.0",
"eslint-plugin-vue": "^8.7.1",
"typescript": "~5.1.6",
"typescript": "~5.3.3",
"vue-cli-plugin-i18n": "^2.3.2",
"webpack-bundle-tracker": "1.8.1",
"webpack-bundle-tracker": "3.0.1",
"workbox-background-sync": "^7.0.0",
"workbox-expiration": "^6.5.4",
"workbox-navigation-preload": "^7.0.0",
@ -86,7 +85,8 @@
"parser": "@typescript-eslint/parser"
},
"rules": {
"no-unused-vars": "off"
"no-unused-vars": "off",
"vue/no-unused-components": "warn"
}
},
"browserslist": [

View File

@ -11,25 +11,36 @@
<b-button variant="primary" v-b-tooltip.hover :title="$t('Create')" @click="createNew">
<i class="fas fa-plus"></i>
</b-button>
<b-dropdown variant="primary" id="sortDropDown" text="Order By" class="border-left">
<b-dropdown-item @click = "orderBy('id','asc')" :disabled= "isActiveSort('id','asc')">oldest to newest</b-dropdown-item>
<b-dropdown-item @click = "orderBy('id','desc')" :disabled= "isActiveSort('id','desc')">newest to oldest</b-dropdown-item>
<b-dropdown-item @click = "orderBy('name','asc')" :disabled= "isActiveSort('name','asc')">alphabetical order</b-dropdown-item>
<b-dropdown-item @click = "orderBy('order','asc')" :disabled= "isActiveSort('order','asc')" >manually</b-dropdown-item>
</b-dropdown>
</b-input-group-append>
<b-button class= "ml-2" variant="primary" v-if="isActiveSort('order','asc')" @click="handleEditButton" >
{{submitText}}
</b-button>
</b-input-group>
</div>
</div>
</div>
</div>
</div>
<div v-if="!isActiveSort('order','asc') || !manSubmitted">
<div style="padding-bottom: 55px">
<div class="mb-3" v-for="book in filteredBooks" :key="book.id">
<div class="mb-3" v-for="(book) in filteredBooks" :key="book.id">
<div class="row">
<div class="col-md-12">
<b-card class="d-flex flex-column" v-hover v-on:click="openBook(book.id)">
<b-row no-gutters style="height: inherit">
<b-col no-gutters md="10" style="height: inherit">
<b-card class="d-flex flex-column" v-hover >
<b-row no-gutters style="height: inherit" class="d-flex align-items-center">
<b-col no-gutters style="height: inherit">
<b-card-body class="m-0 py-0" style="height: inherit">
<b-card-text class="h-100 my-0 d-flex flex-column" style="text-overflow: ellipsis">
<b-button v-on:click="openBook(book.id)" style="color: #000; background-color: white" variant="primary">
<h5 class="m-0 mt-1 text-truncate" >
{{ book.name }} <span class="float-right"><i class="fa fa-book"></i></span>
</h5>
</h5></b-button>
<div class="m-0 text-truncate">{{ book.description }}</div>
<div class="mt-auto mb-1 d-flex flex-row justify-content-end"></div>
</b-card-text>
@ -54,7 +65,36 @@
</div>
</div>
</div>
<div v-else>
<draggable
@change="updateManualSorting"
:list="cookbooks" ghost-class="ghost">
<b-card no-body class="mt-1 list-group-item p-2"
style="cursor: move"
v-for=" (book,index) in filteredBooks"
v-hover
:key="book.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>
</div>
<div class="col-10">
<h5 class="mt-1 mb-1">
<b-badge class="float-left text-white mr-2">
#{{ index + 1 }}
</b-badge>
{{ book.name }}
</h5>
</div>
</div>
</b-card-header>
</b-card>
</draggable>
</div>
<bottom-navigation-bar active-view="view_books">
<template #custom_create_functions>
@ -72,7 +112,7 @@
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import draggable from "vuedraggable"
import "bootstrap-vue/dist/bootstrap-vue.css"
import { ApiApiFactory } from "@/utils/openapi/api"
import CookbookSlider from "@/components/CookbookSlider"
@ -85,7 +125,7 @@ Vue.use(BootstrapVue)
export default {
name: "CookbookView",
mixins: [ApiMixin],
components: { LoadingSpinner, CookbookSlider, BottomNavigationBar },
components: { LoadingSpinner, CookbookSlider, BottomNavigationBar, draggable },
data() {
return {
cookbooks: [],
@ -94,6 +134,11 @@ export default {
current_book: undefined,
loading: false,
search: "",
activeSortField : 'id',
activeSortDirection: 'desc',
inputValue: "",
manSubmitted : false,
submitText: "Edit"
}
},
computed: {
@ -168,6 +213,44 @@ export default {
}
})
},
orderBy: function(order_field,order_direction){
let apiClient = new ApiApiFactory()
const options = {
order_field: order_field,
order_direction: order_direction
}
this.activeSortField = order_field
this.activeSortDirection = order_direction
apiClient.listRecipeBooks(options).then((result) => {
this.cookbooks = result.data
})
},
isActiveSort: function(field, direction) {
// Check if the current item is the active sorting option
return this.activeSortField === field && this.activeSortDirection === direction;
},
handleEditButton: function(){
if (!this.manSubmitted){
this.submitText = "Back"
this.manSubmitted = true
} else {
this.submitText = "Edit"
this.manSubmitted = false
}
},
updateManualSorting: function(){
let old_order = Object.assign({}, this.cookbooks);
let promises = []
this.cookbooks.forEach((element, index) => {
let apiClient = new ApiApiFactory()
promises.push(apiClient.partialUpdateManualOrderBooks(element.id, {order: index}))
})
return Promise.all(promises).then(() => {
}).catch((err) => {
this.cookbooks = old_order
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
}
},
directives: {
hover: {

View File

@ -126,7 +126,7 @@
<div class="flex-grow-1 ml-2"
style="text-overflow: ellipsis; overflow-wrap: anywhere;">
<span class="two-row-text">
<a :href="resolveDjangoUrl('view_recipe', plan.entry.recipe.id)" v-if="plan.entry.recipe">{{ plan.entry.recipe.name }}</a>
<a :href="getRecipeURL(plan.entry.recipe, plan.entry.servings)" v-if="plan.entry.recipe">{{ plan.entry.recipe.name }}</a>
<span v-else>{{ plan.entry.title }}</span> <br/>
</span>
<span v-if="plan.entry.note" class="two-row-text">
@ -169,7 +169,7 @@
v-if="contextData && contextData.originalItem && contextData.originalItem.entry.recipe != null"
@click="
$refs.menu.close()
openRecipe(contextData.originalItem.entry.recipe)
openRecipe(contextData.originalItem.entry.recipe, contextData.originalItem.entry.servings)
"
>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pizza-slice"></i>
@ -392,8 +392,13 @@ export default {
},
},
methods: {
openRecipe: function (recipe) {
window.open(this.resolveDjangoUrl("view_recipe", recipe.id))
getRecipeURL: function (recipe, servings) {
return this.resolveDjangoUrl("view_recipe",`${recipe.id}?servings=${servings}`)
},
openRecipe: function (recipe, servings) {
window.open(this.getRecipeURL(recipe, servings))
},
setStartingDay(days) {
if (this.settings.startingDayOfWeek + days < 0) {

View File

@ -645,7 +645,18 @@
<mavon-editor v-model="step.instruction" :autofocus="false"
style="z-index: auto" :id="'id_instruction_' + step.id"
:language="'en'"
:toolbars="md_editor_toolbars" :defaultOpen="'edit'"/>
:toolbars="md_editor_toolbars" :defaultOpen="'edit'">
<template #left-toolbar-after>
<span class="op-icon-divider"></span>
<button
type="button"
@click="step.instruction+= ' {{ scale(100) }}'"
class="op-icon fas fa-times"
aria-hidden="true"
title="Scalable Number"
></button>
</template>
</mavon-editor>
<!-- TODO markdown DOCS link and markdown editor -->
</div>
@ -832,7 +843,7 @@ export default {
header: true,
underline: true,
strikethrough: true,
mark: true,
mark: false,
superscript: true,
subscript: true,
quote: true,

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,48 @@
<div id="app">
<div>
<markdown-editor-component></markdown-editor-component>
<div class="swipe-container">
<div class="swipe-action bg-success">
<i class="swipe-icon fa-fw fas fa-check"></i>
</div>
<b-button-group class="swipe-element">
<div class="card flex-grow-1 btn-block p-2">
<div class="d-flex">
<div class="d-flex flex-column pr-2">
<span>
<span><i class="fas fa-check"></i> <b>100 g </b></span>
<br/>
</span>
<span>
<span><i class="fas fa-check"></i> <b>200 kg </b></span>
<br/>
</span>
</div>
<div class="d-flex flex-column flex-grow-1 align-self-center">
Erdbeeren <br/>
<span><small class="text-muted">vabene111</small></span>
</div>
</div>
</div>
<b-button variant="success">
<i class="d-print-none fa-fw fas fa-check"></i>
</b-button>
</b-button-group>
<div class="swipe-action bg-primary justify-content-end">
<i class="fa-fw fas fa-hourglass-half swipe-icon"></i>
</div>
</div>
<markdown-editor-component></markdown-editor-component>
</div>
</div>
</template>
@ -49,5 +89,46 @@ export default {
</script>
<style>
.swipe-container {
display: flex;
overflow: auto;
overflow-x: scroll;
scroll-snap-type: x mandatory;
}
/* scrollbar should be hidden */
.swipe-container::-webkit-scrollbar {
display: none;
}
.swipe-container {
scrollbar-width: none; /* For Firefox */
}
/* main element should always snap into view */
.swipe-element {
scroll-snap-align: start;
}
.swipe-icon {
color: white;
position: sticky;
left: 16px;
right: 16px;
}
/* swipe-actions and element should be 100% wide */
.swipe-action,
.swipe-element {
min-width: 100%;
}
.swipe-action {
display: flex;
align-items: center;
}
.right {
justify-content: flex-end;
}
</style>

View File

@ -11,14 +11,14 @@
<div class="flex-column" v-if="show_button_1">
<slot name="button_1">
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_search' }" v-bind:href="resolveDjangoUrl('view_search')">
<i class="fas fa-fw fa-book " style="font-size: 1.5em"></i><br/><small>{{ $t('Recipes') }}</small></a> <!-- TODO localize -->
<i class="fas fa-fw fa-book " style="font-size: 1.4em"></i><br/><small>{{ $t('Recipes') }}</small></a> <!-- TODO localize -->
</slot>
</div>
<div class="flex-column" v-if="show_button_2">
<slot name="button_2">
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_plan' }" v-bind:href="resolveDjangoUrl('view_plan')">
<i class="fas fa-calendar-alt" style="font-size: 1.5em"></i><br/><small>{{ $t('Meal_Plan') }}</small></a>
<i class="fas fa-calendar-alt" style="font-size: 1.4em"></i><br/><small>{{ $t('Meal_Plan') }}</small></a>
</slot>
</div>
@ -53,14 +53,14 @@
<div class="flex-column" v-if="show_button_3">
<slot name="button_3">
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_shopping' }" v-bind:href="resolveDjangoUrl('view_shopping')">
<i class="fas fa-shopping-cart" style="font-size: 1.5em"></i><br/><small>{{ $t('Shopping_list') }}</small></a>
<i class="fas fa-shopping-cart" style="font-size: 1.4em"></i><br/><small>{{ $t('Shopping_list') }}</small></a>
</slot>
</div>
<div class="flex-column">
<slot name="button_4" v-if="show_button_4">
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_books' }" v-bind:href="resolveDjangoUrl('view_books')">
<i class="fas fa-book-open" style="font-size: 1.5em"></i><br/><small>{{ $t('Books') }}</small></a> <!-- TODO localize -->
<i class="fas fa-book-open" style="font-size: 1.4em"></i><br/><small>{{ $t('Books') }}</small></a> <!-- TODO localize -->
</slot>
</div>

View File

@ -6,7 +6,6 @@
</template>
<script>
import html2pdf from "html2pdf.js"
export default {
name: "DownloadPDF",
@ -20,12 +19,7 @@ export default {
},
methods: {
downloadFile() {
const doc = document.querySelector(this.dom)
var options = {
margin: 1,
filename: this.name,
}
html2pdf().from(doc).set(options).save()
window.print()
},
},
}

View File

@ -1,44 +1,60 @@
<template>
<div>
<h1>EDITOR</h1>
<div id="editor" style="" class="bg-info">
<b-button @click="toolbarTest()">Heading</b-button>
<b-button @click="bold()">B</b-button>
<div id="editor" style="background-color: #fff; border: solid 1px">
</div>
</div>
</template>
<script>
import {EditorState} from "@codemirror/state"
import {keymap, EditorView, MatchDecorator, Decoration, WidgetType, ViewPlugin} from "@codemirror/view"
import {defaultKeymap} from "@codemirror/commands"
import {EditorSelection, EditorState} from "@codemirror/state"
import {keymap, EditorView, MatchDecorator, Decoration, WidgetType, ViewPlugin, DecorationSet} from "@codemirror/view"
import {defaultKeymap, history} from "@codemirror/commands"
import {markdown} from "@codemirror/lang-markdown"
import {markdown, markdownLanguage} from "@codemirror/lang-markdown"
import {autocompletion} from "@codemirror/autocomplete"
class PlaceholderWidget extends WidgetType { //TODO this is not working for some javascript magic reason
import {defaultHighlightStyle, syntaxHighlighting, syntaxTree} from "@codemirror/language";
class TemplatePreviewWidget extends WidgetType {
name = undefined
constructor(name) {
console.log(name)
ingredients = []
constructor(name, ingredients) {
super()
this.name = name
this.ingredients = ingredients
}
eq(other) {
return this.name == other.name
getIngredientLabel(ingredient) {
// TODO all possible null combinations
return `${ingredient.amount} ${ingredient.unit.name} ${ingredient.food.name}`
}
toDOM() {
let elt = document.createElement("span")
elt.style.cssText = `
border: 1px solid blue;
border-radius: 4px;
padding: 0 3px;
background: lightblue;`
let preview_span = document.createElement("span")
elt.textContent = "Food"
return elt
let display_text = 'ERROR'
if (this.name.includes('ingredients')) {
let ingredient_index = this.name.replace('{{ ingredients[', '').replace('] }}', '') // TODO support calculations ingredients[0]*0.5
display_text = this.getIngredientLabel(this.ingredients[ingredient_index])
}
if (this.name.includes('scale(')) {
display_text = this.name.replace('{{ scale(', '').replace(') }}', '') // TODO support calculations scale(100)*2
}
preview_span.innerHTML = display_text
preview_span.style.cssText = ` border: 1px solid blue; border-radius: 4px; padding: 0 3px; background: lightblue;`
return preview_span
}
ignoreEvent() {
@ -49,39 +65,69 @@ class PlaceholderWidget extends WidgetType { //TODO this is not working for some
export default {
name: "MarkdownEditorComponent",
props: {},
computed: {},
computed: {
autocomplete_options() {
let autocomplete_options = []
let index = 0
for (let i of this.ingredients) {
autocomplete_options.push({label: i.food.name, type: "text", apply: `{{ ingredients[${index}] }}`, detail: `${i.amount} ${i.unit.name} ${i.food.name}`})
index++
}
autocomplete_options.push({label: "Scale", type: "text", apply: "{{ scale(100) }}", detail: "simple scalable number"})
return autocomplete_options
}
},
data() {
return {
editor_view: null,
ingredients: [
{amount: 20, food: {'name': 'raspberry'}, unit: {'name': 'pcs'}},
{amount: 100, food: {'name': 'sugar'}, unit: {'name': 'g'}},
{amount: 250, food: {'name': 'water'}, unit: {'name': 'ml'}},
{amount: 1, food: {'name': 'salt'}, unit: {'name': 'pinch'}},
]
}
},
mounted() {
const placeholderMatcher = new MatchDecorator({
regexp: /\{\{\singredients\[\d\]\s\}\}/g,
decoration: match => Decoration.replace({
widget: new PlaceholderWidget(match[0]),
})
const decoMatcher = new MatchDecorator({
regexp: /\{\{ (?:scale\(\d+\)|ingredients\[\d+\]) \}\}/g,
decorate: (add, from, to, match, view) => {
const templatePreview = new TemplatePreviewWidget(match[0], this.ingredients);
add(to, to, Decoration.widget({widget: templatePreview, side: 1}));
},
})
const placeholders = ViewPlugin.fromClass(class {
placeholders
decorations
constructor(view) {
this.placeholders = placeholderMatcher.createDeco(view)
this.decorations = decoMatcher.createDeco(view)
}
update(update) {
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders)
update(viewUpdate) {
this.decorations = decoMatcher.updateDeco(viewUpdate, this.decorations)
}
}, {
decorations: instance => instance.placeholders,
provide: plugin => EditorView.atomicRanges.of(view => {
return view.plugin(plugin)?.placeholders || Decoration.none
})
decorations: instance => instance.decorations,
})
let startState = EditorState.create({
doc: "Das ist eine Beschreibung \nPacke {{ ingredients[1] }} in das Fass mit {{ ingredients[3] }}\nTest Bla Bla",
extensions: [keymap.of(defaultKeymap), placeholders, markdown(), autocompletion({override: [this.foodTemplateAutoComplete]})]
doc: "## Header\n\nDas ist eine Beschreibung [test](https://google.com) \nPacke {{ ingredients[1] }} in das Fass mit {{ ingredients[3] }}\nTest Bla Bla {{ scale(100) }} \n\n- test\n- test 2\n- test3",
extensions: [
history(),
syntaxHighlighting(defaultHighlightStyle),
keymap.of(defaultKeymap),
placeholders,
markdown({base: markdownLanguage, highlightFormatting: true}),
autocompletion({override: [this.foodTemplateAutoComplete]})
]
})
let view = new EditorView({
this.editor_view = new EditorView({
state: startState,
extensions: [],
parent: document.getElementById("editor")
@ -90,16 +136,74 @@ export default {
methods: {
foodTemplateAutoComplete: function (context) {
let word = context.matchBefore(/\w*/)
if (word.from == word.to && !context.explicit)
if (word.from === word.to && !context.explicit)
return null
return {
from: word.from,
options: [
{label: "Mehl", type: "text", apply: "{{ ingredients[1] }}", detail: "template"},
{label: "Butter", type: "text", apply: "{{ ingredients[2] }}", detail: "template"},
{label: "Salz", type: "text", apply: "{{ ingredients[3] }}", detail: "template"},
]
options: this.autocomplete_options
}
},
toolbarTest() {
const transaction = this.editor_view.state.changeByRange((range) => {
const prefix = '###'
const docText = this.editor_view.state.doc.toString()
let text = this.editor_view.state.sliceDoc(range.from, range.to)
let rangeFrom = range.from
while (rangeFrom > 0) {
if (docText[rangeFrom - 1] === "\n") {
break
}
rangeFrom -= 1
}
text = this.editor_view.state.sliceDoc(rangeFrom, range.to)
const textBefore = `\n${text}`
const newText = textBefore.replace(/\n/g, `\n${prefix}`)
const changes = {
from: rangeFrom,
to: range.to,
insert: newText,
anchor: rangeFrom + prefix.length + 1,
textBefore: textBefore
}
return {
changes,
range: EditorSelection.range(changes.anchor, changes.anchor)
}
})
this.editor_view.dispatch(transaction)
},
bold() {
const transaction = this.editor_view.state.changeByRange((range) => {
if (range.anchor === range.head) {
console.log('nothing selected --> nothing bold')
} else {
let selected_text = this.editor_view.state.sliceDoc(range.from, range.to)
let new_text = `**${selected_text}**`
const changes = {
from: range.from,
to: range.to,
insert: new_text,
}
return {changes, range: EditorSelection.range(range.anchor + 2, range.head + 2)}
}
})
this.editor_view.dispatch(transaction)
},
heading(editor, heading_size) {
}
},
}

View File

@ -186,8 +186,5 @@ export default {
}
</script>
<style>
.b-form-spinbutton.form-control {
background-color: #e9ecef;
border: 1px solid #ced4da;
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<div>
<b-button-group class="w-100 mt-1">
<b-button @click="updateNumber( 'half')" variant="outline-info"
:disabled="disable"><i class="fas fa-divide"></i> 2
</b-button>
<b-button variant="outline-info" @click="updateNumber( 'sub')"
:disabled="disable"><i class="fas fa-minus"></i>
</b-button>
<b-button variant="outline-info" @click="updateNumber('prompt')"
:disabled="disable">
{{ number }}
</b-button>
<b-button variant="outline-info" @click="updateNumber( 'add')"
:disabled="disable"><i class="fas fa-plus"></i>
</b-button>
<b-button @click="updateNumber('multiply')" variant="outline-info"
:disabled="disable"><i class="fas fa-times"></i> 2
</b-button>
</b-button-group>
</div>
</template>
<script>
import Vue from "vue"
import {BootstrapVue} from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
Vue.use(BootstrapVue)
export default {
name: "NumberScalerComponent",
props: {
number: {type: Number, default:0},
disable: {type: Boolean, default: false}
},
data() {
return {}
},
methods: {
/**
* perform given operation on linked number
* @param operation update mode
*/
updateNumber: function(operation) {
if (operation === 'half') {
this.$emit('change', this.number / 2)
}
if (operation === 'multiply') {
this.$emit('change', this.number * 2)
}
if (operation === 'add') {
this.$emit('change', this.number + 1)
}
if (operation === 'sub') {
this.$emit('change', this.number - 1)
}
if (operation === 'prompt') {
let input_number = prompt(this.$t('Input'), this.number);
if (input_number !== null && input_number !== "" && !isNaN(input_number) && !isNaN(parseFloat(input_number))) {
this.$emit('change', parseFloat(input_number))
} else {
console.log('Invalid number input in prompt', input_number)
}
}
},
},
}
</script>
<style scoped>
</style>

View File

@ -237,6 +237,7 @@ export default {
},
props: {
recipe_id: Number,
def_servings: Number,
recipe_obj: {type: Object, default: null},
show_context_menu: {type: Boolean, default: true},
enable_keyword_links: {type: Boolean, default: true},
@ -320,8 +321,13 @@ export default {
if (this.recipe.image === null) this.printReady()
window.RECIPE_SERVINGS = Number(window.RECIPE_SERVINGS)
if (window.RECIPE_SERVINGS && ! isNaN(window.RECIPE_SERVINGS)) {
//I am not sure this is the best way. This overwrites our servings cache, which may not be intended?
this.servings = window.RECIPE_SERVINGS
} else {
this.servings = this.servings_cache[this.rootrecipe.id] = this.recipe.servings
}
this.loading = false
setTimeout(() => {

View File

@ -1,10 +1,10 @@
<template>
<div v-if="user_preferences !== undefined">
<div v-if="useUserPreferenceStore().user_settings !== undefined">
<b-form-group :label="$t('shopping_share')" :description="$t('shopping_share_desc')">
<generic-multiselect
@change="user_preferences.shopping_share = $event.val; updateSettings(false)"
@change="useUserPreferenceStore().user_settings.shopping_share = $event.val; updateSettings(false)"
:model="Models.USER"
:initial_selection="user_preferences.shopping_share"
:initial_selection="useUserPreferenceStore().user_settings.shopping_share"
label="display_name"
:multiple="true"
:placeholder="$t('User')"
@ -12,101 +12,98 @@
</b-form-group>
<b-form-group :label="$t('shopping_auto_sync')" :description="$t('shopping_auto_sync_desc')">
<b-form-input type="range" :min="SHOPPING_MIN_AUTOSYNC_INTERVAL" max="60" step="1" v-model="user_preferences.shopping_auto_sync"
@change="updateSettings(false)"></b-form-input>
<b-form-input type="range" :min="SHOPPING_MIN_AUTOSYNC_INTERVAL" max="60" step="1" v-model="useUserPreferenceStore().user_settings.shopping_auto_sync"
@change="updateSettings(false)" :disabled="useUserPreferenceStore().user_settings.shopping_auto_sync < 1"></b-form-input>
<div class="text-center">
<span v-if="user_preferences.shopping_auto_sync > 0">
{{ Math.round(user_preferences.shopping_auto_sync) }}
<span v-if="user_preferences.shopping_auto_sync === 1">{{ $t('Second') }}</span>
<span v-if="useUserPreferenceStore().user_settings.shopping_auto_sync > 0">
{{ Math.round(useUserPreferenceStore().user_settings.shopping_auto_sync) }}
<span v-if="useUserPreferenceStore().user_settings.shopping_auto_sync === 1">{{ $t('Second') }}</span>
<span v-else> {{ $t('Seconds') }}</span>
</span>
<span v-if="user_preferences.shopping_auto_sync < 1">{{ $t('Disable') }}</span>
<span v-if="useUserPreferenceStore().user_settings.shopping_auto_sync < 1">{{ $t('Disable') }}</span>
</div>
<br/>
<b-button class="btn btn-sm" @click="user_preferences.shopping_auto_sync = 0; updateSettings(false)">{{ $t('Disabled') }}</b-button>
<b-button class="btn btn-sm" @click="useUserPreferenceStore().user_settings.shopping_auto_sync = 0; updateSettings(false)"
v-if="useUserPreferenceStore().user_settings.shopping_auto_sync > 0">{{ $t('Disable') }}</b-button>
<b-button class="btn btn-sm btn-success" @click="useUserPreferenceStore().user_settings.shopping_auto_sync = SHOPPING_MIN_AUTOSYNC_INTERVAL; updateSettings(false)"
v-if="useUserPreferenceStore().user_settings.shopping_auto_sync < 1">{{ $t('Enable') }}</b-button>
</b-form-group>
<b-form-group :description="$t('mealplan_autoadd_shopping_desc')">
<b-form-checkbox v-model="user_preferences.mealplan_autoadd_shopping"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.mealplan_autoadd_shopping"
@change="updateSettings(false)">{{ $t('mealplan_autoadd_shopping') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :description="$t('mealplan_autoexclude_onhand_desc')">
<b-form-checkbox v-model="user_preferences.mealplan_autoexclude_onhand"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.mealplan_autoexclude_onhand"
@change="updateSettings(false)">{{ $t('mealplan_autoexclude_onhand') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :description="$t('mealplan_autoinclude_related_desc')">
<b-form-checkbox v-model="user_preferences.mealplan_autoinclude_related"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.mealplan_autoinclude_related"
@change="updateSettings(false)">{{ $t('mealplan_autoinclude_related') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :description="$t('shopping_add_onhand_desc')">
<b-form-checkbox v-model="user_preferences.shopping_add_onhand"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.shopping_add_onhand"
@change="updateSettings(false)">{{ $t('shopping_add_onhand') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :label="$t('default_delay')" :description="$t('default_delay_desc')">
<b-form-input type="range" min="1" max="72" step="1" v-model="user_preferences.default_delay"
<b-form-input type="range" min="1" max="72" step="1" v-model="useUserPreferenceStore().user_settings.default_delay"
@change="updateSettings(false)"></b-form-input>
<div class="text-center">
<span>{{ Math.round(user_preferences.default_delay) }}
<span v-if="user_preferences.default_delay === 1">{{ $t('Hour') }}</span>
<span>{{ Math.round(useUserPreferenceStore().user_settings.default_delay) }}
<span v-if="useUserPreferenceStore().user_settings.default_delay === 1">{{ $t('Hour') }}</span>
<span v-else> {{ $t('Hours') }}</span>
</span>
</div>
</b-form-group>
<b-form-group :description="$t('filter_to_supermarket_desc')">
<b-form-checkbox v-model="user_preferences.filter_to_supermarket"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.filter_to_supermarket"
@change="updateSettings(false)">{{ $t('filter_to_supermarket') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :label="$t('shopping_recent_days')" :description="$t('shopping_recent_days_desc')">
<b-form-input type="range" min="0" max="14" step="1" v-model="user_preferences.shopping_recent_days"
<b-form-input type="range" min="0" max="14" step="1" v-model="useUserPreferenceStore().user_settings.shopping_recent_days"
@change="updateSettings(false)"></b-form-input>
<div class="text-center">
<span>{{ Math.round(user_preferences.shopping_recent_days) }}
<span v-if="user_preferences.shopping_recent_days === 1">{{ $t('Day') }}</span>
<span>{{ Math.round(useUserPreferenceStore().user_settings.shopping_recent_days) }}
<span v-if="useUserPreferenceStore().user_settings.shopping_recent_days === 1">{{ $t('Day') }}</span>
<span v-else> {{ $t('Days') }}</span>
</span>
</div>
</b-form-group>
<b-form-group :label="$t('csv_delim_label')" :description="$t('csv_delim_help')">
<b-form-input v-model="user_preferences.csv_delim" @change="updateSettings(false)"></b-form-input>
<b-form-input v-model="useUserPreferenceStore().user_settings.csv_delim" @change="updateSettings(false)"></b-form-input>
</b-form-group>
<b-form-group :label="$t('csv_prefix_label')" :description="$t('csv_prefix_help')">
<b-form-input v-model="user_preferences.csv_prefix" @change="updateSettings(false)"></b-form-input>
<b-form-input v-model="useUserPreferenceStore().user_settings.csv_prefix" @change="updateSettings(false)"></b-form-input>
</b-form-group>
</div>
</template>
<script>
import {ApiApiFactory} from "@/utils/openapi/api";
import {ApiMixin, StandardToasts} from "@/utils/utils";
import axios from "axios";
import GenericMultiselect from "@/components/GenericMultiselect";
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
export default {
name: "ShoppingSettingsComponent",
mixins: [ApiMixin],
components: {GenericMultiselect},
props: {
user_id: Number,
},
props: { },
data() {
return {
user_preferences: undefined,
@ -115,30 +112,15 @@ export default {
}
},
mounted() {
this.user_preferences = this.preferences
this.languages = window.AVAILABLE_LANGUAGES
this.loadSettings()
useUserPreferenceStore().loadUserSettings(false)
},
methods: {
loadSettings: function () {
let apiFactory = new ApiApiFactory()
apiFactory.retrieveUserPreference(this.user_id.toString()).then(result => {
this.user_preferences = result.data
}).catch(err => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
})
},
useUserPreferenceStore,
updateSettings: function (reload) {
let apiFactory = new ApiApiFactory()
this.$emit('updated', this.user_preferences)
apiFactory.partialUpdateUserPreference(this.user_id.toString(), this.user_preferences).then(result => {
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
if (reload) {
location.reload()
}
}).catch(err => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
useUserPreferenceStore().updateUserSettings()
},
}
}

View File

@ -1,176 +1,134 @@
<template>
<div id="shopping_line_item" class="pt-1">
<b-row align-h="start"
ref="shopping_line_item" class="invis-border">
<b-col cols="2" 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>
<div class="swipe-container" :id="item_container_id" @touchend="handleSwipe()"
v-if="(useUserPreferenceStore().device_settings.shopping_show_checked_entries || !is_checked) && (useUserPreferenceStore().device_settings.shopping_show_delayed_entries || !is_delayed)"
>
<div class="swipe-action" :class="{'bg-success': !is_checked , 'bg-warning': is_checked }">
<i class="swipe-icon fa-fw fas" :class="{'fa-check': !is_checked , 'fa-cart-plus': is_checked }"></i>
</div>
<b-button-group class="swipe-element">
<b-button variant="primary" v-if="is_delayed">
<i class="fa-fw fas fa-hourglass-half"></i>
</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-0 pl-1 pr-md-3 pl-md-3 dropdown-toggle-no-caret">
<i class="fas fa-ellipsis-v"></i>
</button>
<div class="card flex-grow-1 btn-block p-2" @click="detail_modal_visible = true">
<div class="d-flex">
<div class="d-flex flex-column pr-2" v-if="Object.keys(amounts).length> 0">
<span v-for="a in amounts" v-bind:key="a.id">
<span><i class="fas fa-check" v-if="a.checked && !is_checked"></i><i class="fas fa-hourglass-half" v-if="a.delayed && !a.checked"></i> <b>{{ a.amount }} {{
a.unit
}} </b></span>
<br/></span>
</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">
<b-row class="d-flex h-100">
<b-col cols="6" md="3" class="d-flex align-items-center" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
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="6" md="3" class="d-flex flex-column" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
v-if="Object.entries(formatAmount).length != 1">
<div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">
{{ x[1] }} &ensp;
{{ x[0] }}
<div class="d-flex flex-column flex-grow-1 align-self-center">
{{ food.name }} <br/>
<span v-if="info_row"><small class="text-muted">{{ info_row }}</small></span>
</div>
</div>
</div>
<b-button variant="success" @click="useShoppingListStore().setEntriesCheckedState(entries, !is_checked, true)"
:class="{'btn-success': !is_checked, 'btn-warning': is_checked}">
<i class="d-print-none fa-fw fas" :class="{'fa-check': !is_checked , 'fa-cart-plus': is_checked }"></i>
</b-button>
</b-button-group>
<div class="swipe-action bg-primary justify-content-end">
<i class="fa-fw fas fa-hourglass-half swipe-icon"></i>
</div>
<b-modal v-model="detail_modal_visible" @hidden="detail_modal_visible = false" body-class="pr-4 pl-4 pt-0">
<template #modal-title>
<h5> {{ food_row }}</h5>
<small class="text-muted">{{ food.description }}</small>
</template>
<template #default>
<h5 class="mt-2">{{ $t('Quick actions') }}</h5>
{{ $t('Category') }}
<b-form-select
class="form-control mb-2"
:options="useShoppingListStore().supermarket_categories"
text-field="name"
value-field="id"
v-model="food.supermarket_category"
@change="detail_modal_visible = false; updateFoodCategory(food)"
></b-form-select>
<b-button variant="info" block
@click="detail_modal_visible = false;useShoppingListStore().delayEntries(entries,!is_delayed, true)">
{{ $t('Postpone') }}
</b-button>
<h6 class="mt-2">{{ $t('Entries') }}</h6>
<b-row v-for="e in entries" v-bind:key="e.id">
<b-col cold="12">
<b-button-group class="w-100">
<div class="card flex-grow-1 btn-block p-2">
<span><i class="fas fa-check" v-if="e.checked"></i><i class="fas fa-hourglass-half" v-if="e.delay_until !== null && !e.checked"></i>
<b><span v-if="e.amount > 0">{{ e.amount }}</span> {{ e.unit?.name }}</b> {{ food.name }}</span>
<span><small class="text-muted">
<span v-if="e.recipe_mealplan && e.recipe_mealplan.recipe_name !== ''">
<a :href="resolveDjangoUrl('view_recipe', e.recipe_mealplan.recipe)"> <b> {{
e.recipe_mealplan.recipe_name
}} </b></a>({{
e.recipe_mealplan.servings
}} {{ $t('Servings') }})<br/>
</span>
<span
v-if="e.recipe_mealplan && e.recipe_mealplan.mealplan_type !== undefined"> {{
e.recipe_mealplan.mealplan_type
}} {{ formatDate(e.recipe_mealplan.mealplan_from_date) }} <br/></span>
{{ e.created_by.display_name }} {{ formatDate(e.created_at) }}<br/>
</small></span>
</div>
<b-button variant="outline-danger"
@click="useShoppingListStore().deleteObject(e)"><i
class="fas fa-trash"></i></b-button>
</b-button-group>
<generic-multiselect
class="mt-1"
v-if="e.recipe_mealplan === null"
:initial_single_selection="e.unit"
:model="Models.UNIT"
:multiple="false"
@change="e.unit = $event.val; useShoppingListStore().updateObject(e)"
>
</generic-multiselect>
<number-scaler-component :number="e.amount"
@change="e.amount = $event; useShoppingListStore().updateObject(e)"
v-if="e.recipe_mealplan === null"></number-scaler-component>
<hr class="m-2"/>
</b-col>
<b-col cols="6" 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" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
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"><span class="ml-2">{{
$t("Details")
}}</span></span>
</div>
</b-row>
<b-button variant="success" block @click="useShoppingListStore().createObject({ amount: 0, unit: null, food: food, })"> {{ $t("Add") }}</b-button>
<b-button variant="warning" block @click="detail_modal_visible = false; setFoodIgnoredAndChecked(food)"> {{ $t("Ignore_Shopping") }}</b-button>
<b-button variant="danger" block class="mt-2"
@click="detail_modal_visible = false;useShoppingListStore().deleteEntries(entries)">
{{ $t('Delete_All') }}
</b-button>
</b-col>
</b-row>
</b-col>
<b-col cols="2" class="justify-content-start align-items-center d-flex d-md-none pl-0 pr-0" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
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>
<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="2" md="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 pr-md-3 pl-md-3 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="7" 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 mt-1 mb-1 mt-md-3 mb-md-3" v-if="x !== entries.length - 1"/>
<div class="pb-1 pb-md-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>
</div>
</b-form-group>
</ContextMenuItem>
<template #modal-footer>
<span></span>
</template>
</ContextMenu>
<i class="fa fa-hourglass fa-lg" style="display: none; position: absolute" aria-hidden="true"
ref="delay_icon"></i>
<i class="fa fa-check fa-lg" style="display: none; position: absolute" aria-hidden="true" ref="check_icon"></i>
</b-modal>
<generic-modal-form :model="Models.FOOD" :show="editing_food !== null"
@hidden="editing_food = null; useShoppingListStore().refreshFromAPI()"></generic-modal-form>
</div>
</template>
@ -178,292 +136,237 @@
import Vue from "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 RecipeCard from "./RecipeCard.vue"
import Vue2TouchEvents from "vue2-touch-events"
import {ApiMixin, FormatMixin, resolveDjangoUrl, StandardToasts} from "@/utils/utils"
import {useMealPlanStore} from "@/stores/MealPlanStore";
import {useShoppingListStore} from "@/stores/ShoppingListStore";
import {ApiApiFactory} from "@/utils/openapi/api";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import NumberScalerComponent from "@/components/NumberScalerComponent.vue";
import GenericModalForm from "@/components/Modals/GenericModalForm.vue";
import GenericMultiselect from "@/components/GenericMultiselect.vue";
Vue.use(BootstrapVue)
Vue.use(Vue2TouchEvents)
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},
mixins: [ApiMixin, FormatMixin],
components: {GenericMultiselect, GenericModalForm, NumberScalerComponent},
props: {
entries: {
type: Array,
},
settings: Object,
groupby: {type: String},
entries: {type: Object,},
},
data() {
return {
showDetails: false,
recipe: undefined,
servings: 1,
dragStartX: 0,
distance_left: 0
detail_modal_visible: false,
editing_food: null,
}
},
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
item_container_id: function () {
let id = 'id_sli_'
for (let i in this.entries) {
id += i + '_'
}
return id
},
is_checked: function () {
for (let i in this.entries) {
if (!this.entries[i].checked) {
return false
}
}
})
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 true
},
is_delayed: function () {
for (let i in this.entries) {
if (Date.parse(this.entries[i].delay_until) > new Date(Date.now())) {
return true
}
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
}
return false
},
formatFood: function () {
return this.formatOneFood(this.entries[0])
food: function () {
return this.entries[Object.keys(this.entries)[0]]['food']
},
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(" ")
amounts: function () {
let unit_amounts = {}
return mealplan_name
.map((x) => {
return this.formatOneMealPlan(x)
})
.join(" - ")
for (let i in this.entries) {
let e = this.entries[i]
if (!e.checked && e.delay_until === null
|| (e.checked && useUserPreferenceStore().device_settings.shopping_show_checked_entries)
|| (e.delay_until !== null && useUserPreferenceStore().device_settings.shopping_show_delayed_entries)) {
let unit = -1
if (e.unit !== undefined && e.unit !== null) {
unit = e.unit.id
}
},
formatNotes: function () {
if (this.entries?.length == 1) {
return this.formatOneNote(this.entries[0]) || ""
if (e.amount > 0) {
if (unit in unit_amounts) {
unit_amounts[unit]['amount'] += e.amount
} else {
if (unit === -1) {
unit_amounts[unit] = {id: -1, unit: "", amount: e.amount, checked: e.checked, delayed: (e.delay_until !== null)}
} else {
unit_amounts[unit] = {id: e.unit.id, unit: e.unit.name, amount: e.amount, checked: e.checked, delayed: (e.delay_until !== null)}
}
return ""
}
}
}
}
return unit_amounts
},
food_row: function () {
return this.food.name
},
info_row: function () {
let info_row = []
let authors = []
let recipes = []
let meal_pans = []
for (let i in this.entries) {
let e = this.entries[i]
if (authors.indexOf(e.created_by.display_name) === -1) {
authors.push(e.created_by.display_name)
}
if (e.recipe_mealplan !== null) {
let recipe_name = e.recipe_mealplan.recipe_name
if (recipes.indexOf(recipe_name) === -1) {
recipes.push(recipe_name.substring(0, 14) + (recipe_name.length > 14 ? '..' : ''))
}
if ('mealplan_from_date' in e.recipe_mealplan) {
let meal_plan_entry = (e?.recipe_mealplan?.mealplan_type || '') + ' (' + this.formatDate(e.recipe_mealplan.mealplan_from_date) + ')'
if (meal_pans.indexOf(meal_plan_entry) === -1) {
meal_pans.push(meal_plan_entry)
}
}
}
}
if (useUserPreferenceStore().device_settings.shopping_item_info_created_by && authors.length > 0) {
info_row.push(authors.join(', '))
}
if (useUserPreferenceStore().device_settings.shopping_item_info_recipe && recipes.length > 0) {
info_row.push(recipes.join(', '))
}
if (useUserPreferenceStore().device_settings.shopping_item_info_mealplan && meal_pans.length > 0) {
info_row.push(meal_pans.join(', '))
}
return info_row.join(' - ')
}
},
watch: {},
mounted() {
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
},
methods: {
// this.genericAPI inherited from ApiMixin
useUserPreferenceStore,
useShoppingListStore,
resolveDjangoUrl,
/**
* update the food after the category was changed
* handle changing category to category ID as a workaround
* @param food
*/
updateFoodCategory: function (food) {
if (typeof food.supermarket_category === "number") { // not the best solution, but as long as generic multiselect does not support caching, I don't want to use a proper model
food.supermarket_category = this.useShoppingListStore().supermarket_categories.filter(sc => sc.id === food.supermarket_category)[0]
}
formatDate: function (datetime) {
if (!datetime) {
return
}
return Intl.DateTimeFormat(window.navigator.language, {
dateStyle: "short",
timeStyle: "short",
}).format(Date.parse(datetime))
},
startHandler: function (event) {
if (event.changedTouches.length > 0) {
this.dragStartX = event.changedTouches[0].clientX
}
},
getOffset(el) {
let rect = el.getBoundingClientRect();
return {
left: rect.left + window.scrollX,
top: rect.top + window.scrollY,
right: rect.right - window.scrollX,
};
},
moveHandler: function (event) {
let item = this.$refs['shopping_line_item'];
this.distance_left = event.changedTouches[0].clientX - this.dragStartX;
item.style.marginLeft = this.distance_left
item.style.marginRight = -this.distance_left
item.style.backgroundColor = '#ddbf86'
item.style.border = "1px solid #000"
let apiClient = new ApiApiFactory()
apiClient.updateFood(food.id, food).then(r => {
let delay_icon = this.$refs['delay_icon']
let check_icon = this.$refs['check_icon']
let color_factor = Math.abs(this.distance_left) / 100
if (this.distance_left > 0) {
item.parentElement.parentElement.style.backgroundColor = 'rgba(130,170,139,0)'.replace(/[^,]+(?=\))/, color_factor)
check_icon.style.display = "block"
check_icon.style.left = this.getOffset(item.parentElement.parentElement).left + 40
check_icon.style.top = this.getOffset(item.parentElement.parentElement).top - 92
check_icon.style.opacity = color_factor - 0.3
} else {
item.parentElement.parentElement.style.backgroundColor = 'rgba(185,135,102,0)'.replace(/[^,]+(?=\))/, color_factor)
delay_icon.style.display = "block"
delay_icon.style.left = this.getOffset(item.parentElement.parentElement).right - 40
delay_icon.style.top = this.getOffset(item.parentElement.parentElement).top - 92
delay_icon.style.opacity = color_factor - 0.3
}
},
endHandler: function (event) {
let item = this.$refs['shopping_line_item'];
item.removeAttribute('style');
item.parentElement.parentElement.removeAttribute('style');
let delay_icon = this.$refs['delay_icon']
let check_icon = this.$refs['check_icon']
delay_icon.style.display = "none"
check_icon.style.display = "none"
if (Math.abs(this.distance_left) > window.screen.width / 6) {
if (this.distance_left > 0) {
let checked = false;
this.entries.forEach((cur) => {
checked = cur.checked
})
let update = {entries: this.entries.map((x) => x.id), checked: !checked}
this.$emit("update-checkbox", update)
} else {
this.$emit("update-delaythis", this.entries)
}
}
},
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.display_name, "@", 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)
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
},
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}
}
console.log(update)
this.$emit("update-checkbox", update)
/**
* set food on_hand status to true and check all associated entries
* @param food
*/
setFoodIgnoredAndChecked: function (food) {
let apiClient = new ApiApiFactory()
food.ignore_shopping = true
apiClient.updateFood(food.id, food).then(r => {
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
useShoppingListStore().setEntriesCheckedState(this.entries, true, false)
},
/**
* function triggered by touchend event of swipe container
* check if min distance is reached and execute desired action
*/
handleSwipe: function () {
const minDistance = 80;
const container = document.querySelector('#' + this.item_container_id);
// get the distance the user swiped
const swipeDistance = container.scrollLeft - container.clientWidth;
if (swipeDistance < minDistance * -1) {
useShoppingListStore().setEntriesCheckedState(this.entries, !this.is_checked, true)
} else if (swipeDistance > minDistance) {
useShoppingListStore().delayEntries(this.entries, !this.is_delayed, true)
}
}
},
}
</script>
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
<style>
/* table { border-collapse:collapse } /* Ensure no space between cells */
/* tr.strikeout td { position:relative } /* Setup a new coordinate system */
/* tr.strikeout td:before { /* Create a new element that */
/* content: " "; /* …has no text content */
/* position: absolute; /* …is absolutely positioned */
/* left: 0; top: 50%; width: 100%; /* …with the top across the middle */
/* border-bottom: 1px solid #000; /* …and with a border on the top */
/* } */
.checkbox-control {
font-size: 0.6rem;
/* scroll snap takes care of restoring scroll position */
.swipe-container {
display: flex;
overflow: auto;
overflow-x: scroll;
scroll-snap-type: x mandatory;
}
.checkbox-control-mobile {
font-size: 1rem;
/* scrollbar should be hidden */
.swipe-container::-webkit-scrollbar {
display: none;
}
.rotate {
-moz-transition: all 0.25s linear;
-webkit-transition: all 0.25s linear;
transition: all 0.25s linear;
.swipe-container {
scrollbar-width: none; /* For Firefox */
}
.rotated {
-moz-transform: rotate(90deg);
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
/* main element should always snap into view */
.swipe-element {
scroll-snap-align: start;
}
.unit-badge-lg {
font-size: 1rem !important;
font-weight: 500 !important;
.swipe-icon {
color: white;
position: sticky;
left: 16px;
right: 16px;
}
@media (max-width: 768px) {
.dropdown-spacing {
padding-left: 0 !important;
padding-right: 0 !important;
}
/* swipe-actions and element should be 100% wide */
.swipe-action,
.swipe-element {
min-width: 100%;
}
.invis-border {
border: 1px solid transparent;
.swipe-action {
display: flex;
align-items: center;
}
@media (min-width: 992px) {
.fa-ellipsis-v {
font-size: 20px;
}
.right {
justify-content: flex-end;
}
@media (min-width: 576px) {
.fa-ellipsis-v {
font-size: 16px;
}
}
</style>

View File

@ -158,7 +158,7 @@
"Create_New_Unit": "Neue Einheit hinzufügen",
"Instructions": "Anleitung",
"Time": "Zeit",
"New_Keyword": "Neues Schlagwort",
"New_Keyword": "Neues Stichwort",
"Delete_Keyword": "Schlagwort löschen",
"show_split_screen": "Geteilte Ansicht",
"Recipes_per_page": "Rezepte pro Seite",
@ -555,5 +555,13 @@
"FDC_ID_help": "FDC Datenbank ID",
"CustomImageHelp": "Laden Sie ein Bild hoch, das in der Space-Übersicht angezeigt werden soll.",
"CustomNavLogoHelp": "Laden Sie ein Bild hoch, das als Logo für die Navigationsleiste verwendet werden soll.",
"CustomLogos": "Individuelle Logos"
"CustomLogos": "Individuelle Logos",
"Input": "Eingabe",
"Undo": "Rückgängig",
"NoMoreUndo": "Rückgängig: Keine Änderungen",
"created_by": "Erstellt von",
"ShoppingBackgroundSyncWarning": "Schlechte Netzwerkverbindung, Warten auf Synchronisation ...",
"ShowRecentlyCompleted": "Zuletzt abgehakte Zutaten zeigen",
"Enable": "Aktivieren",
"Delete_All": "Alles löschen"
}

View File

@ -96,6 +96,9 @@
"base_unit": "Base Unit",
"base_amount": "Base Amount",
"Datatype": "Datatype",
"Input": "Input",
"Undo": "Undo",
"NoMoreUndo": "No changes to be undone.",
"Number of Objects": "Number of Objects",
"Add_Step": "Add Step",
"Keywords": "Keywords",
@ -141,6 +144,7 @@
"Edit": "Edit",
"Image": "Image",
"Delete": "Delete",
"Delete_All": "Delete all",
"Open": "Open",
"Ok": "Ok",
"Save": "Save",
@ -239,6 +243,7 @@
"Week": "Week",
"Month": "Month",
"Year": "Year",
"created_by": "Created by",
"Planner": "Planner",
"Planner_Settings": "Planner settings",
"Period": "Period",
@ -295,9 +300,11 @@
"Warning": "Warning",
"NoCategory": "No category selected.",
"InheritWarning": "{food} is set to inherit, changes may not persist.",
"ShowDelayed": "Show Delayed Items",
"ShowDelayed": "Show delayed items",
"ShowRecentlyCompleted": "Show recently completed items",
"Completed": "Completed",
"OfflineAlert": "You are offline, shopping list may not syncronize.",
"ShoppingBackgroundSyncWarning": "Bad network, waiting to sync ...",
"shopping_share": "Share Shopping List",
"shopping_auto_sync": "Autosync",
"one_url_per_line": "One URL per line",
@ -496,6 +503,7 @@
"Reset": "Reset",
"Disabled": "Disabled",
"Disable": "Disable",
"Enable": "Enable",
"Options": "Options",
"Create Food": "Create Food",
"create_food_desc": "Create a food and link it to this recipe.",

View File

@ -554,5 +554,13 @@
"CustomLogos": "Własne loga",
"Show_Logo": "Pokaż logo",
"Nav_Text_Mode": "Tryb nawigacji tekstowej",
"Nav_Text_Mode_Help": "Zachowuje się inaczej dla każdego motywu."
"Nav_Text_Mode_Help": "Zachowuje się inaczej dla każdego motywu.",
"Input": "Wprowadź",
"Undo": "Cofnij",
"NoMoreUndo": "Brak zmian do wycofania.",
"Delete_All": "Usuń wszystko",
"ShowRecentlyCompleted": "Pokaż ostatnio zakończone elementy",
"ShoppingBackgroundSyncWarning": "Słaba sieć, oczekiwanie na synchronizację...",
"Enable": "Włączyć",
"created_by": "Stworzone przez"
}

View File

@ -480,5 +480,83 @@
"Create Recipe": "创建食谱",
"Import Recipe": "导入食谱",
"recipe_property_info": "您也可以为食物添加属性,以便根据食谱自动计算!",
"per_serving": "每份"
"per_serving": "每份",
"converted_amount": "换算量",
"Open_Data_Import": "开放数据导入",
"StartDate": "开始日期",
"EndDate": "结束日期",
"OrderInformation": "对象按照从小到大的顺序排列。",
"total": "全部",
"pound": "磅(重量)",
"imperial_quart": "英制夸脱【imp qt】英制体积",
"imperial_pint": "英制品脱【imp pt】英制体积",
"imperial_tbsp": "英制汤匙【imp tbsp】英制体积",
"make_now_count": "至多缺少的成分",
"l": "升【l】公制体积",
"Welcome": "欢迎",
"Input": "输入",
"Undo": "撤销",
"Number of Objects": "对象数量",
"Alignment": "校准",
"Delete_All": "全部删除",
"Conversion": "转换",
"Properties": "属性",
"ShowRecentlyCompleted": "显示最近完成的项目",
"ShoppingBackgroundSyncWarning": "网络状况不佳,正在等待进行同步……",
"show_step_ingredients_setting": "在食谱步骤旁边显示成分",
"show_step_ingredients_setting_help": "在食谱步骤旁边添加成分表。在创建时应用。可以在编辑配方视图中覆盖。",
"show_step_ingredients": "显示该步骤的成分",
"hide_step_ingredients": "隐藏该步骤的成分",
"Logo": "徽标",
"Show_Logo": "显示徽标",
"Show_Logo_Help": "在导航栏中显示 Tandoor 或空间徽标。",
"Nav_Text_Mode": "文本导航模式",
"Nav_Text_Mode_Help": "每个主题的行为都不同。",
"Space_Cosmetic_Settings": "空间管理员可以更改某些装饰设置,并将覆盖该空间的客户端设置。",
"show_ingredients_table": "在步骤文本旁边显示成分表",
"Enable": "启用",
"g": "克【g】公制重量",
"gallon": "加仑【gal】美制体积",
"tbsp": "汤匙【tbsp】美制体积",
"tsp": "茶匙【tsp】美制体积",
"imperial_gallon": "英制加仑【imp gal】英制体积",
"imperial_tsp": "英制茶匙【imp tsp】英制体积",
"Choose_Category": "选择类别",
"Back": "后退",
"Food_Replace": "食物替换",
"Unit_Replace": "单位替换",
"Datatype": "数据类型",
"NoMoreUndo": "没有可撤消的更改。",
"FDC_Search": "FDC搜索",
"FDC_ID_help": "FDC数据库ID",
"property_type_fdc_hint": "只有具有 FDC ID 的属性类型才能自动从 FDC 数据库中提取数据",
"Data_Import_Info": "通过导入社区精选的食物、单位等列表来增强您的空间,以提升您的食谱收藏。",
"Update_Existing_Data": "更新现有数据",
"Use_Metric": "使用公制单位",
"Learn_More": "了解更多",
"converted_unit": "换算单位",
"base_unit": "基本单位",
"base_amount": "基本量",
"Property": "属性",
"Property_Editor": "属性编辑器",
"imperial_fluid_ounce": "英制液体盎司【imp fl oz】英制体积",
"kg": "千克【kg】公制重量",
"ounce": "盎司【oz】重量",
"ml": "毫升【ml】公制体积",
"fluid_ounce": "液体盎司【fl oz】美制体积",
"pint": "品脱 【pt】美制体积",
"quart": "夸脱【qt】美制体积",
"Name_Replace": "名称替换",
"FDC_ID": "FDC ID",
"err_importing_recipe": "导入菜谱时出错!",
"open_data_help_text": "Tandoor开放数据项目为Tandoor提供社区贡献的数据。该字段在导入时会自动填充并可以之后更新。",
"Open_Data_Slug": "开放数据标识",
"Properties_Food_Amount": "食物数量属性",
"Properties_Food_Unit": "食品单位属性",
"CustomTheme": "自定义主题",
"CustomThemeHelp": "通过上传自定义 CSS 文件覆盖所选主题的样式。",
"CustomImageHelp": "上传图片以在空间概览中显示。",
"CustomNavLogoHelp": "上传图像以用作导航栏徽标。",
"CustomLogoHelp": "上传不同尺寸的方形图像以更改为浏览器选项卡和安装的网络应用程序中的徽标。",
"CustomLogos": "自定义徽标"
}

View File

@ -0,0 +1,491 @@
import {ApiApiFactory} from "@/utils/openapi/api"
import {StandardToasts} from "@/utils/utils"
import {defineStore} from "pinia"
import Vue from "vue"
import _ from 'lodash';
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import moment from "moment/moment";
const _STORE_ID = "shopping_list_store"
/*
* test store to play around with pinia and see if it can work for my use cases
* don't trust that all shopping list entries are in store as there is no cache validation logic, its just a shared data holder
* */
export const useShoppingListStore = defineStore(_STORE_ID, {
state: () => ({
// shopping data
entries: {},
supermarket_categories: [],
supermarkets: [],
total_unchecked: 0,
total_checked: 0,
total_unchecked_food: 0,
total_checked_food: 0,
// internal
currently_updating: false,
last_autosync: null,
autosync_has_focus: true,
autosync_timeout_id: null,
undo_stack: [],
queue_timeout_id: undefined,
item_check_sync_queue: {},
// constants
GROUP_CATEGORY: 'food.supermarket_category.name',
GROUP_CREATED_BY: 'created_by.display_name',
GROUP_RECIPE: 'recipe_mealplan.recipe_name',
UNDEFINED_CATEGORY: 'shopping_undefined_category'
}),
getters: {
/**
* build a multi-level data structure ready for display from shopping list entries
* group by selected grouping key
* @return {{}}
*/
get_entries_by_group: function () {
let structure = {}
let ordered_structure = []
// build structure
for (let i in this.entries) {
structure = this.updateEntryInStructure(structure, this.entries[i], useUserPreferenceStore().device_settings.shopping_selected_grouping)
}
// statistics for UI conditions and display
let total_unchecked = 0
let total_checked = 0
let total_unchecked_food = 0
let total_checked_food = 0
for (let i in structure) {
let count_unchecked = 0
let count_checked = 0
let count_unchecked_food = 0
let count_checked_food = 0
for (let fi in structure[i]['foods']) {
let food_checked = true
for (let ei in structure[i]['foods'][fi]['entries']) {
if (structure[i]['foods'][fi]['entries'][ei].checked) {
count_checked++
} else {
food_checked = false
count_unchecked++
}
}
if (food_checked) {
count_checked_food++
} else {
count_unchecked_food++
}
}
Vue.set(structure[i], 'count_unchecked', count_unchecked)
Vue.set(structure[i], 'count_checked', count_checked)
Vue.set(structure[i], 'count_unchecked_food', count_unchecked_food)
Vue.set(structure[i], 'count_checked_food', count_checked_food)
total_unchecked += count_unchecked
total_checked += count_checked
total_unchecked_food += count_unchecked_food
total_checked_food += count_checked_food
}
this.total_unchecked = total_unchecked
this.total_checked = total_checked
this.total_unchecked_food = total_unchecked_food
this.total_checked_food = total_checked_food
// ordering
if (this.UNDEFINED_CATEGORY in structure) {
ordered_structure.push(structure[this.UNDEFINED_CATEGORY])
Vue.delete(structure, this.UNDEFINED_CATEGORY)
}
if (useUserPreferenceStore().device_settings.shopping_selected_grouping === this.GROUP_CATEGORY && useUserPreferenceStore().device_settings.shopping_selected_supermarket !== null) {
for (let c of useUserPreferenceStore().device_settings.shopping_selected_supermarket.category_to_supermarket) {
if (c.category.name in structure) {
ordered_structure.push(structure[c.category.name])
Vue.delete(structure, c.category.name)
}
}
if (!useUserPreferenceStore().device_settings.shopping_show_selected_supermarket_only) {
for (let i in structure) {
ordered_structure.push(structure[i])
}
}
} else {
for (let i in structure) {
ordered_structure.push(structure[i])
}
}
return ordered_structure
},
/**
* flattened list of entries used for exporters
* kinda uncool but works for now
* @return {*[]}
*/
get_flat_entries: function () {
let items = []
for (let i in this.get_entries_by_group) {
for (let f in this.get_entries_by_group[i]['foods']) {
for (let e in this.get_entries_by_group[i]['foods'][f]['entries']) {
items.push({
amount: this.get_entries_by_group[i]['foods'][f]['entries'][e].amount,
unit: this.get_entries_by_group[i]['foods'][f]['entries'][e].unit?.name ?? '',
food: this.get_entries_by_group[i]['foods'][f]['entries'][e].food?.name ?? '',
})
}
}
}
return items
},
/**
* list of options available for grouping entry display
* @return {[{id: *, translatable_label: string},{id: *, translatable_label: string},{id: *, translatable_label: string}]}
*/
grouping_options: function () {
return [
{'id': this.GROUP_CATEGORY, 'translatable_label': 'Category'},
{'id': this.GROUP_CREATED_BY, 'translatable_label': 'created_by'},
{'id': this.GROUP_RECIPE, 'translatable_label': 'Recipe'}
]
},
/**
* checks if failed items are contained in the sync queue
*/
has_failed_items: function () {
for (let i in this.item_check_sync_queue) {
if (this.item_check_sync_queue[i]['status'] === 'syncing_failed_before' || this.item_check_sync_queue[i]['status'] === 'waiting_failed_before') {
return true
}
}
return false
}
},
actions: {
/**
* Retrieves all shopping related data (shopping list entries, supermarkets, supermarket categories and shopping list recipes) from API
*/
refreshFromAPI() {
if (!this.currently_updating) {
this.currently_updating = true
this.last_autosync = new Date().getTime();
let apiClient = new ApiApiFactory()
apiClient.listShoppingListEntrys().then((r) => {
this.entries = {}
r.data.forEach((e) => {
Vue.set(this.entries, e.id, e)
})
this.currently_updating = false
}).catch((err) => {
this.currently_updating = false
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
})
apiClient.listSupermarketCategorys().then(r => {
this.supermarket_categories = r.data
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
})
apiClient.listSupermarkets().then(r => {
this.supermarkets = r.data
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
})
}
},
/**
* perform auto sync request to special endpoint returning only entries changed since last auto sync
* only updates local entries that are older than the server version
*/
autosync() {
if (!this.currently_updating && this.autosync_has_focus) {
console.log('running autosync')
this.currently_updating = true
let previous_autosync = this.last_autosync
this.last_autosync = new Date().getTime();
let apiClient = new ApiApiFactory()
apiClient.listShoppingListEntrys(undefined, undefined, undefined, {
'query': {'last_autosync': previous_autosync}
}).then((r) => {
r.data.forEach((e) => {
// dont update stale client data
if (!(Object.keys(this.entries).includes(e.id.toString())) || Date.parse(this.entries[e.id].updated_at) < Date.parse(e.updated_at)) {
console.log('auto sync updating entry ', e)
Vue.set(this.entries, e.id, e)
}
})
this.currently_updating = false
}).catch((err) => {
console.warn('auto sync failed')
this.currently_updating = false
})
}
},
/**
* Create a new shopping list entry
* adds new entry to store
* @param object entry object to create
* @return {Promise<T | void>} promise of creation call to subscribe to
*/
createObject(object) {
let apiClient = new ApiApiFactory()
return apiClient.createShoppingListEntry(object).then((r) => {
Vue.set(this.entries, r.data.id, r.data)
this.registerChange('CREATED', {[r.data.id]: r.data},)
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
},
/**
* update existing entry object and updated_at timestamp
* updates data in store
* IMPORTANT: always use this method to update objects to keep client state consistent
* @param object entry object to update
* @return {Promise<T | void>} promise of updating call to subscribe to
*/
updateObject(object) {
let apiClient = new ApiApiFactory()
// sets the update_at timestamp on the client to prevent auto sync from overriding with older changes
// moment().format() yields locale aware datetime without ms 2024-01-04T13:39:08.607238+01:00
Vue.set(object, 'updated_at', moment().format())
return apiClient.updateShoppingListEntry(object.id, object).then((r) => {
Vue.set(this.entries, r.data.id, r.data)
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
},
/**
* delete shopping list entry object from DB and store
* @param object entry object to delete
* @return {Promise<T | void>} promise of delete call to subscribe to
*/
deleteObject(object) {
let apiClient = new ApiApiFactory()
return apiClient.destroyShoppingListEntry(object.id).then((r) => {
Vue.delete(this.entries, object.id)
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_DELETE, err)
})
},
/**
* returns a distinct list of recipes associated with unchecked shopping list entries
*/
getAssociatedRecipes: function () {
let recipes = {}
for (let i in this.entries) {
let e = this.entries[i]
if (e.recipe_mealplan !== null) {
Vue.set(recipes, e.recipe_mealplan.recipe, {
'shopping_list_recipe_id': e.list_recipe,
'recipe_id': e.recipe_mealplan.recipe,
'recipe_name': e.recipe_mealplan.recipe_name,
'servings': e.recipe_mealplan.servings,
'mealplan_from_date': e.recipe_mealplan.mealplan_from_date,
'mealplan_type': e.recipe_mealplan.mealplan_type,
})
}
}
return recipes
},
// convenience methods
/**
* function to set entry to its proper place in the data structure to perform grouping
* @param {{}} structure datastructure
* @param {*} entry entry to place
* @param {*} group group to place entry into (must be of ShoppingListStore.GROUP_XXX/dot notation of entry property)
* @returns {{}} datastructure including entry
*/
updateEntryInStructure(structure, entry, group) {
let grouping_key = _.get(entry, group, this.UNDEFINED_CATEGORY)
if (grouping_key === undefined || grouping_key === null) {
grouping_key = this.UNDEFINED_CATEGORY
}
if (!(grouping_key in structure)) {
Vue.set(structure, grouping_key, {'name': grouping_key, 'foods': {}})
}
if (!(entry.food.id in structure[grouping_key]['foods'])) {
Vue.set(structure[grouping_key]['foods'], entry.food.id, {
'id': entry.food.id,
'name': entry.food.name,
'entries': {}
})
}
Vue.set(structure[grouping_key]['foods'][entry.food.id]['entries'], entry.id, entry)
return structure
},
/**
* function to handle user checking or unchecking a set of entries
* @param {{}} entries set of entries
* @param checked boolean to set checked state of entry to
* @param undo if the user should be able to undo the change or not
*/
setEntriesCheckedState(entries, checked, undo) {
if (undo) {
this.registerChange((checked ? 'CHECKED' : 'UNCHECKED'), entries)
}
let entry_id_list = []
for (let i in entries) {
Vue.set(this.entries[i], 'checked', checked)
Vue.set(this.entries[i], 'updated_at', moment().format())
entry_id_list.push(i)
}
this.item_check_sync_queue
Vue.set(this.item_check_sync_queue, Math.random(), {
'ids': entry_id_list,
'checked': checked,
'status': 'waiting'
})
this.runSyncQueue(5)
},
/**
* go through the list of queued requests and try to run them
* add request back to queue if it fails due to offline or timeout
* Do NOT call this method directly, always call using runSyncQueue method to prevent simultaneous runs
* @private
*/
_replaySyncQueue() {
if (navigator.onLine || document.location.href.includes('localhost')) {
let apiClient = new ApiApiFactory()
let promises = []
for (let i in this.item_check_sync_queue) {
let entry = this.item_check_sync_queue[i]
Vue.set(entry, 'status', ((entry['status'] === 'waiting') ? 'syncing' : 'syncing_failed_before'))
Vue.set(this.item_check_sync_queue, i, entry)
let p = apiClient.bulkShoppingListEntry(entry, {timeout: 15000}).then((r) => {
Vue.delete(this.item_check_sync_queue, i)
}).catch((err) => {
if (err.code === "ERR_NETWORK" || err.code === "ECONNABORTED") {
Vue.set(entry, 'status', 'waiting_failed_before')
Vue.set(this.item_check_sync_queue, i, entry)
} else {
Vue.delete(this.item_check_sync_queue, i)
console.error('Failed API call for entry ', entry)
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
}
})
promises.push(p)
}
Promise.allSettled(promises).finally(r => {
this.runSyncQueue(500)
})
} else {
this.runSyncQueue(5000)
}
},
/**
* manages running the replaySyncQueue function after the given timeout
* calling this function might cancel a previously created timeout
* @param timeout time in ms after which to run the replaySyncQueue function
*/
runSyncQueue(timeout) {
clearTimeout(this.queue_timeout_id)
this.queue_timeout_id = setTimeout(() => {
this._replaySyncQueue()
}, timeout)
},
/**
* function to handle user "delaying" and "undelaying" shopping entries
* @param {{}} entries set of entries
* @param delay if entries should be delayed or if delay should be removed
* @param undo if the user should be able to undo the change or not
*/
delayEntries(entries, delay, undo) {
let delay_hours = useUserPreferenceStore().user_settings.default_delay
let delay_date = new Date(Date.now() + delay_hours * (60 * 60 * 1000))
if (undo) {
this.registerChange((delay ? 'DELAY' : 'UNDELAY'), entries)
}
for (let i in entries) {
this.entries[i].delay_until = (delay ? delay_date : null)
this.updateObject(this.entries[i])
}
},
/**
* delete list of entries
* @param {{}} entries set of entries
*/
deleteEntries(entries) {
for (let i in entries) {
this.deleteObject(this.entries[i])
}
},
deleteShoppingListRecipe(shopping_list_recipe_id) {
let api = new ApiApiFactory()
for (let i in this.entries) {
if (this.entries[i].list_recipe === shopping_list_recipe_id) {
Vue.delete(this.entries, i)
}
}
api.destroyShoppingListRecipe(shopping_list_recipe_id).then((x) => {
// no need to update anything, entries were already removed
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_DELETE, err)
})
},
/**
* register the change to a set of entries to allow undoing it
* throws an Error if the operation type is not known
* @param type the type of change to register. This determines what undoing the change does. (CREATE->delete object,
* CHECKED->uncheck entry, UNCHECKED->check entry, DELAY->remove delay)
* @param {{}} entries set of entries
*/
registerChange(type, entries) {
if (!['CREATED', 'CHECKED', 'UNCHECKED', 'DELAY', 'UNDELAY'].includes(type)) {
throw Error('Tried to register unknown change type')
}
this.undo_stack.push({'type': type, 'entries': entries})
},
/**
* takes the last item from the undo stack and reverts it
*/
undoChange() {
let last_item = this.undo_stack.pop()
if (last_item !== undefined) {
let type = last_item['type']
let entries = last_item['entries']
if (type === 'CHECKED' || type === 'UNCHECKED') {
this.setEntriesCheckedState(entries, (type === 'UNCHECKED'), false)
} else if (type === 'DELAY' || type === 'UNDELAY') {
this.delayEntries(entries, (type === 'UNDELAY'), false)
} else if (type === 'CREATED') {
for (let i in entries) {
let e = entries[i]
this.deleteObject(e)
}
}
} else {
// can use localization in store
//StandardToasts.makeStandardToast(this, this.$t('NoMoreUndo'))
}
}
},
})

View File

@ -1,20 +1,136 @@
import {defineStore} from 'pinia'
import {ApiApiFactory} from "@/utils/openapi/api";
import {ApiApiFactory, UserPreference} from "@/utils/openapi/api";
import Vue from "vue";
import {StandardToasts} from "@/utils/utils";
const _STALE_TIME_IN_MS = 1000 * 30
const _STORE_ID = 'user_preference_store'
const _LS_DEVICE_SETTINGS = 'TANDOOR_LOCAL_SETTINGS'
const _LS_USER_SETTINGS = 'TANDOOR_USER_SETTINGS'
const _USER_ID = localStorage.getItem('USER_ID')
export const useUserPreferenceStore = defineStore(_STORE_ID, {
state: () => ({
data: null,
updated_at: null,
currently_updating: false,
user_settings_loaded_at: new Date(0),
user_settings: {
image: null,
theme: "TANDOOR",
nav_bg_color: "#ddbf86",
nav_text_color: "DARK",
nav_show_logo: true,
default_unit: "g",
default_page: "SEARCH",
use_fractions: false,
use_kj: false,
plan_share: [],
nav_sticky: true,
ingredient_decimals: 2,
comments: true,
shopping_auto_sync: 5,
mealplan_autoadd_shopping: false,
food_inherit_default: [],
default_delay: "4.0000",
mealplan_autoinclude_related: true,
mealplan_autoexclude_onhand: true,
shopping_share: [],
shopping_recent_days: 7,
csv_delim: ",",
csv_prefix: "",
filter_to_supermarket: false,
shopping_add_onhand: false,
left_handed: false,
show_step_ingredients: true,
food_children_exist: false,
locally_updated_at: new Date(0),
},
device_settings_initialized: false,
device_settings_loaded_at: new Date(0),
device_settings: {
// shopping
shopping_show_checked_entries: false,
shopping_show_delayed_entries: false,
shopping_show_selected_supermarket_only: false,
shopping_selected_grouping: 'food.supermarket_category.name',
shopping_selected_supermarket: null,
shopping_item_info_created_by: false,
shopping_item_info_mealplan: false,
shopping_item_info_recipe: true,
},
}),
getters: {
getters: {},
actions: {
// Device settings (on device settings stored in local storage)
/**
* Load device settings from local storage and update state device_settings
*/
loadDeviceSettings() {
let s = localStorage.getItem(_LS_DEVICE_SETTINGS)
if (!(s === null || s === {})) {
let settings = JSON.parse(s)
for (s in settings) {
Vue.set(this.device_settings, s, settings[s])
}
}
this.device_settings_initialized = true
},
/**
* persist changes to device settings into local storage
*/
updateDeviceSettings: function () {
localStorage.setItem(_LS_DEVICE_SETTINGS, JSON.stringify(this.device_settings))
},
// ---------------- new methods for user settings
loadUserSettings: function (allow_cached_results) {
let s = localStorage.getItem(_LS_USER_SETTINGS)
if (!(s === null || s === {})) {
let settings = JSON.parse(s)
for (s in settings) {
Vue.set(this.user_settings, s, settings[s])
}
console.log(`loaded local user settings age ${((new Date().getTime()) - this.user_settings.locally_updated_at) / 1000} `)
}
if (((new Date().getTime()) - this.user_settings.locally_updated_at) > _STALE_TIME_IN_MS || !allow_cached_results) {
console.log('refreshing user settings from API')
let apiClient = new ApiApiFactory()
apiClient.retrieveUserPreference(localStorage.getItem('USER_ID')).then(r => {
for (s in r.data) {
if (!(s in this.user_settings) && s !== 'user') {
// dont load new keys if no default exists (to prevent forgetting to add defaults)
console.error(`API returned UserPreference key "${s}" which has no default in UserPreferenceStore.user_settings.`)
} else {
Vue.set(this.user_settings, s, r.data[s])
}
}
Vue.set(this.user_settings, 'locally_updated_at', new Date().getTime())
localStorage.setItem(_LS_USER_SETTINGS, JSON.stringify(this.user_settings))
}).catch(err => {
this.currently_updating = false
})
}
},
actions: {
updateUserSettings: function () {
let apiClient = new ApiApiFactory()
apiClient.partialUpdateUserPreference(_USER_ID, this.user_settings).then(r => {
this.user_settings = r.data
Vue.set(this.user_settings, 'locally_updated_at', new Date().getTime())
localStorage.setItem(_LS_USER_SETTINGS, JSON.stringify(this.user_settings))
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
}).catch(err => {
this.currently_updating = false
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
},
// ----------------
// User Preferences (database settings stored in user preference model)
/**
* gets data from the store either directly or refreshes from API if data is considered stale
* @returns {UserPreference|*|Promise<axios.AxiosResponse<UserPreference>>}
@ -75,6 +191,10 @@ export const useUserPreferenceStore = defineStore(_STORE_ID, {
this.data = r.data
this.updated_at = new Date()
this.currently_updating = false
this.user_settings = r.data
this.user_settings_loaded_at = new Date()
return this.data
}).catch(err => {
this.currently_updating = false

View File

@ -408,6 +408,7 @@ export class Models {
static SHOPPING_CATEGORY = {
name: "Shopping_Category",
apiName: "SupermarketCategory",
merge: true,
create: {
params: [["name", "description"]],
form: {

View File

@ -4013,18 +4013,6 @@ export interface ShoppingListEntries {
* @memberof ShoppingListEntries
*/
unit?: FoodPropertiesFoodUnit | null;
/**
*
* @type {number}
* @memberof ShoppingListEntries
*/
ingredient?: number | null;
/**
*
* @type {string}
* @memberof ShoppingListEntries
*/
ingredient_note?: string;
/**
*
* @type {string}
@ -4061,6 +4049,12 @@ export interface ShoppingListEntries {
* @memberof ShoppingListEntries
*/
created_at?: string;
/**
*
* @type {string}
* @memberof ShoppingListEntries
*/
updated_at?: string;
/**
*
* @type {string}
@ -4104,18 +4098,6 @@ export interface ShoppingListEntry {
* @memberof ShoppingListEntry
*/
unit?: FoodPropertiesFoodUnit | null;
/**
*
* @type {number}
* @memberof ShoppingListEntry
*/
ingredient?: number | null;
/**
*
* @type {string}
* @memberof ShoppingListEntry
*/
ingredient_note?: string;
/**
*
* @type {string}
@ -4152,6 +4134,12 @@ export interface ShoppingListEntry {
* @memberof ShoppingListEntry
*/
created_at?: string;
/**
*
* @type {string}
* @memberof ShoppingListEntry
*/
updated_at?: string;
/**
*
* @type {string}
@ -4165,6 +4153,25 @@ export interface ShoppingListEntry {
*/
delay_until?: string | null;
}
/**
*
* @export
* @interface ShoppingListEntryBulk
*/
export interface ShoppingListEntryBulk {
/**
*
* @type {Array<any>}
* @memberof ShoppingListEntryBulk
*/
ids: Array<any>;
/**
*
* @type {boolean}
* @memberof ShoppingListEntryBulk
*/
checked: boolean;
}
/**
*
* @export
@ -4213,6 +4220,18 @@ export interface ShoppingListRecipe {
* @memberof ShoppingListRecipe
*/
mealplan_note?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipe
*/
mealplan_from_date?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipe
*/
mealplan_type?: string;
}
/**
*
@ -4262,6 +4281,18 @@ export interface ShoppingListRecipeMealplan {
* @memberof ShoppingListRecipeMealplan
*/
mealplan_note?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipeMealplan
*/
mealplan_from_date?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipeMealplan
*/
mealplan_type?: string;
}
/**
*
@ -4311,6 +4342,18 @@ export interface ShoppingListRecipes {
* @memberof ShoppingListRecipes
*/
mealplan_note?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipes
*/
mealplan_from_date?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipes
*/
mealplan_type?: string;
}
/**
*
@ -4507,19 +4550,97 @@ export interface Space {
* @memberof Space
*/
nav_logo?: RecipeFile | null;
/**
*
* @type {string}
* @memberof Space
*/
space_theme?: SpaceSpaceThemeEnum;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
space_theme?: RecipeFile | null;
custom_space_theme?: RecipeFile | null;
/**
*
* @type {boolean}
* @type {string}
* @memberof Space
*/
use_plural?: boolean;
nav_bg_color?: string;
/**
*
* @type {string}
* @memberof Space
*/
nav_text_color?: SpaceNavTextColorEnum;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_32?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_128?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_144?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_180?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_192?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_512?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_svg?: RecipeFile | null;
}
/**
* @export
* @enum {string}
*/
export enum SpaceSpaceThemeEnum {
Blank = 'BLANK',
Tandoor = 'TANDOOR',
Bootstrap = 'BOOTSTRAP',
Darkly = 'DARKLY',
Flatly = 'FLATLY',
Superhero = 'SUPERHERO',
TandoorDark = 'TANDOOR_DARK'
}
/**
* @export
* @enum {string}
*/
export enum SpaceNavTextColorEnum {
Blank = 'BLANK',
Light = 'LIGHT',
Dark = 'DARK'
}
/**
*
* @export
@ -5382,6 +5503,39 @@ export interface ViewLog {
*/
export const ApiApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {ShoppingListEntryBulk} [shoppingListEntryBulk]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
bulkShoppingListEntry: async (shoppingListEntryBulk?: ShoppingListEntryBulk, options: any = {}): Promise<RequestArgs> => {
const localVarPath = `/api/shopping-list-entry/bulk/`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(shoppingListEntryBulk, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {AccessToken} [accessToken]
@ -9018,6 +9172,13 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
const localVarQueryParameter = {} as any;
if (options.order_field !== undefined) {
localVarQueryParameter['order_field'] = options.order_field;
}
if (options.order_direction!== undefined) {
localVarQueryParameter['order_direction'] = options.order_direction;
}
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
@ -9486,10 +9647,11 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
},
/**
*
* @param {string} [query] Query string matched against supermarket name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listSupermarkets: async (options: any = {}): Promise<RequestArgs> => {
listSupermarkets: async (query?: string, options: any = {}): Promise<RequestArgs> => {
const localVarPath = `/api/supermarket/`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -9502,6 +9664,10 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
if (query !== undefined) {
localVarQueryParameter['query'] = query;
}
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
@ -9661,10 +9827,11 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
},
/**
*
* @param {string} [query] Query string matched against user-file name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listUserFiles: async (options: any = {}): Promise<RequestArgs> => {
listUserFiles: async (query?: string, options: any = {}): Promise<RequestArgs> => {
const localVarPath = `/api/user-file/`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -9677,6 +9844,10 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
if (query !== undefined) {
localVarQueryParameter['query'] = query;
}
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
@ -9935,6 +10106,47 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id A unique integer value identifying this supermarket category.
* @param {string} target
* @param {SupermarketCategory} [supermarketCategory]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
mergeSupermarketCategory: async (id: string, target: string, supermarketCategory?: SupermarketCategory, options: any = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('mergeSupermarketCategory', 'id', id)
// verify required parameter 'target' is not null or undefined
assertParamExists('mergeSupermarketCategory', 'target', target)
const localVarPath = `/api/supermarket-category/{id}/merge/{target}/`
.replace(`{${"id"}}`, encodeURIComponent(String(id)))
.replace(`{${"target"}}`, encodeURIComponent(String(target)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(supermarketCategory, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id A unique integer value identifying this unit.
@ -10058,6 +10270,40 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id A unique integer value identifying the book.
* @param {RecipeBook} [recipeBook]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
partialUpdateManualOrderBooks: async (id: string, recipeBook?: RecipeBook , options: any = {}): Promise<RequestArgs> => {
const localVarPath = `/api/recipe-book/{id}/`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(recipeBook , localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id A unique integer value identifying this access token.
@ -14820,6 +15066,16 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
export const ApiApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = ApiApiAxiosParamCreator(configuration)
return {
/**
*
* @param {ShoppingListEntryBulk} [shoppingListEntryBulk]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async bulkShoppingListEntry(shoppingListEntryBulk?: ShoppingListEntryBulk, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ShoppingListEntryBulk>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.bulkShoppingListEntry(shoppingListEntryBulk, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {AccessToken} [accessToken]
@ -16034,11 +16290,12 @@ export const ApiApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {string} [query] Query string matched against supermarket name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listSupermarkets(options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<Supermarket>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listSupermarkets(options);
async listSupermarkets(query?: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<Supermarket>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listSupermarkets(query, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -16085,11 +16342,12 @@ export const ApiApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {string} [query] Query string matched against user-file name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listUserFiles(options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<UserFile>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listUserFiles(options);
async listUserFiles(query?: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<UserFile>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listUserFiles(query, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -16165,6 +16423,18 @@ export const ApiApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergeKeyword(id, target, keyword, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id A unique integer value identifying this supermarket category.
* @param {string} target
* @param {SupermarketCategory} [supermarketCategory]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async mergeSupermarketCategory(id: string, target: string, supermarketCategory?: SupermarketCategory, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SupermarketCategory>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergeSupermarketCategory(id, target, supermarketCategory, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id A unique integer value identifying this unit.
@ -16201,6 +16471,17 @@ export const ApiApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.moveKeyword(id, parent, keyword, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id A unique integer value identifying this supermarket category relation.
* @param {RecipeBook} [recipeBook]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async partialUpdateManualOrderBooks(id: string, recipeBook?: RecipeBook, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SupermarketCategoryRelation>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.partialUpdateManualOrderBooks(id, recipeBook, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id A unique integer value identifying this access token.
@ -17623,6 +17904,15 @@ export const ApiApiFp = function(configuration?: Configuration) {
export const ApiApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = ApiApiFp(configuration)
return {
/**
*
* @param {ShoppingListEntryBulk} [shoppingListEntryBulk]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
bulkShoppingListEntry(shoppingListEntryBulk?: ShoppingListEntryBulk, options?: any): AxiosPromise<ShoppingListEntryBulk> {
return localVarFp.bulkShoppingListEntry(shoppingListEntryBulk, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AccessToken} [accessToken]
@ -18719,11 +19009,12 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
},
/**
*
* @param {string} [query] Query string matched against supermarket name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listSupermarkets(options?: any): AxiosPromise<Array<Supermarket>> {
return localVarFp.listSupermarkets(options).then((request) => request(axios, basePath));
listSupermarkets(query?: string, options?: any): AxiosPromise<Array<Supermarket>> {
return localVarFp.listSupermarkets(query, options).then((request) => request(axios, basePath));
},
/**
*
@ -18765,11 +19056,12 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
},
/**
*
* @param {string} [query] Query string matched against user-file name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listUserFiles(options?: any): AxiosPromise<Array<UserFile>> {
return localVarFp.listUserFiles(options).then((request) => request(axios, basePath));
listUserFiles(query?: string, options?: any): AxiosPromise<Array<UserFile>> {
return localVarFp.listUserFiles(query, options).then((request) => request(axios, basePath));
},
/**
*
@ -18837,6 +19129,17 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
mergeKeyword(id: string, target: string, keyword?: Keyword, options?: any): AxiosPromise<Keyword> {
return localVarFp.mergeKeyword(id, target, keyword, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id A unique integer value identifying this supermarket category.
* @param {string} target
* @param {SupermarketCategory} [supermarketCategory]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
mergeSupermarketCategory(id: string, target: string, supermarketCategory?: SupermarketCategory, options?: any): AxiosPromise<SupermarketCategory> {
return localVarFp.mergeSupermarketCategory(id, target, supermarketCategory, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id A unique integer value identifying this unit.
@ -18870,6 +19173,16 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
moveKeyword(id: string, parent: string, keyword?: Keyword, options?: any): AxiosPromise<Keyword> {
return localVarFp.moveKeyword(id, parent, keyword, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id A unique integer value identifying this supermarket category relation.
* @param {RecipeBook} [recipeBook]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
partialUpdateManualOrderBooks(id: string, recipeBook?: RecipeBook, options?: any): AxiosPromise<SupermarketCategoryRelation> {
return localVarFp.partialUpdateManualOrderBooks(id, recipeBook, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id A unique integer value identifying this access token.
@ -20160,6 +20473,17 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
* @extends {BaseAPI}
*/
export class ApiApi extends BaseAPI {
/**
*
* @param {ShoppingListEntryBulk} [shoppingListEntryBulk]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public bulkShoppingListEntry(shoppingListEntryBulk?: ShoppingListEntryBulk, options?: any) {
return ApiApiFp(this.configuration).bulkShoppingListEntry(shoppingListEntryBulk, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AccessToken} [accessToken]
@ -20445,7 +20769,6 @@ export class ApiApi extends BaseAPI {
public createRecipeBookEntry(recipeBookEntry?: RecipeBookEntry, options?: any) {
return ApiApiFp(this.configuration).createRecipeBookEntry(recipeBookEntry, options).then((request) => request(this.axios, this.basePath));
}
/**
* function to retrieve a recipe from a given url or source string :param request: standard request with additional post parameters - url: url to use for importing recipe - data: if no url is given recipe is imported from provided source data - (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes :return: JsonResponse containing the parsed json and images
* @param {any} [body]
@ -21492,12 +21815,13 @@ export class ApiApi extends BaseAPI {
/**
*
* @param {string} [query] Query string matched against supermarket name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public listSupermarkets(options?: any) {
return ApiApiFp(this.configuration).listSupermarkets(options).then((request) => request(this.axios, this.basePath));
public listSupermarkets(query?: string, options?: any) {
return ApiApiFp(this.configuration).listSupermarkets(query, options).then((request) => request(this.axios, this.basePath));
}
/**
@ -21548,12 +21872,13 @@ export class ApiApi extends BaseAPI {
/**
*
* @param {string} [query] Query string matched against user-file name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public listUserFiles(options?: any) {
return ApiApiFp(this.configuration).listUserFiles(options).then((request) => request(this.axios, this.basePath));
public listUserFiles(query?: string, options?: any) {
return ApiApiFp(this.configuration).listUserFiles(query, options).then((request) => request(this.axios, this.basePath));
}
/**
@ -21636,6 +21961,19 @@ export class ApiApi extends BaseAPI {
return ApiApiFp(this.configuration).mergeKeyword(id, target, keyword, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id A unique integer value identifying this supermarket category.
* @param {string} target
* @param {SupermarketCategory} [supermarketCategory]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public mergeSupermarketCategory(id: string, target: string, supermarketCategory?: SupermarketCategory, options?: any) {
return ApiApiFp(this.configuration).mergeSupermarketCategory(id, target, supermarketCategory, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id A unique integer value identifying this unit.
@ -21674,7 +22012,16 @@ export class ApiApi extends BaseAPI {
public moveKeyword(id: string, parent: string, keyword?: Keyword, options?: any) {
return ApiApiFp(this.configuration).moveKeyword(id, parent, keyword, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id A unique integer value identifying this supermarket category relation.
* @param {RecipeBook} [recipeBook]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public partialUpdateManualOrderBooks(id: string, recipeBook?: RecipeBook, options?: any) {
return ApiApiFp(this.configuration).partialUpdateManualOrderBooks(id, recipeBook, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id A unique integer value identifying this access token.

View File

@ -131,7 +131,7 @@ export class StandardToasts {
}
let DEBUG = localStorage.getItem("DEBUG") === "True" || always_show_errors
let DEBUG = (localStorage.getItem("DEBUG") === "True" || always_show_errors) && variant !== 'success'
if (DEBUG){
console.log('ERROR ', err, JSON.stringify(err?.response?.data))
console.trace();
@ -365,6 +365,23 @@ export function energyHeading() {
}
}
export const FormatMixin = {
name: "FormatMixin",
methods: {
/**
* format short date from datetime
* @param datetime any string that can be parsed by Date.parse()
* @return {string}
*/
formatDate: function (datetime) {
return Intl.DateTimeFormat(window.navigator.language, {
dateStyle: "short",
}).format(Date.parse(datetime))
},
},
}
axios.defaults.xsrfCookieName = "csrftoken"
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
@ -376,7 +393,7 @@ export const ApiMixin = {
}
},
methods: {
// if passing parameters that are not part of the offical schema of the endpoint use parameter: options: {query: {simple: 1}}
// if passing parameters that are not part of the official schema of the endpoint use parameter: options: {query: {simple: 1}}
genericAPI: function (model, action, options) {
let setup = getConfig(model, action)
if (setup?.config?.function) {

View File

@ -1198,7 +1198,7 @@
resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4":
"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8"
integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==
@ -1341,17 +1341,17 @@
"@codemirror/view" "^6.0.0"
crelt "^1.0.5"
"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.4", "@codemirror/state@^6.2.0", "@codemirror/state@^6.3.3":
version "6.3.3"
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.3.3.tgz#6a647c2fa62b68604187152de497e91aabf43f82"
integrity sha512-0wufKcTw2dEwEaADajjHf6hBy1sh3M6V0e+q4JKIhLuiMSe5td5HOWpUdvKth1fT1M9VYOboajoBHpkCd7PG7A==
"@codemirror/state@^6.0.0", "@codemirror/state@^6.2.0", "@codemirror/state@^6.3.3", "@codemirror/state@^6.4.0":
version "6.4.0"
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.4.0.tgz#8bc3e096c84360b34525a84696a84f86b305363a"
integrity sha512-hm8XshYj5Fo30Bb922QX9hXB/bxOAVH+qaqHBzw5TKa72vOeslyGwd4X8M0c1dJ9JqxlaMceOQ8RsL9tC7gU0A==
"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.22.2":
version "6.22.2"
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.22.2.tgz#79a4b87f5bb3f057cb046295b102eb04fd31a50d"
integrity sha512-cJp64cPXm7QfSBWEXK+76+hsZCGHupUgy8JAbSzMG6Lr0rfK73c1CaWITVW6hZVkOnAFxJTxd0PIuynNbzxYPw==
"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.1":
version "6.23.1"
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.23.1.tgz#1ce3039a588d6b93f153b7c4c035c2075ede34a6"
integrity sha512-J2Xnn5lFYT1ZN/5ewEoMBCmLlL71lZ3mBdb7cUEuHhX2ESoSrNEucpsDXpX22EuTGm9LOgC9v4Z0wx+Ez8QmGA==
dependencies:
"@codemirror/state" "^6.1.4"
"@codemirror/state" "^6.4.0"
style-mod "^4.1.0"
w3c-keyname "^2.2.4"
@ -2046,11 +2046,6 @@
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.8.tgz#f2a7de3c107b89b441e071d5472e6b726b4adf45"
integrity sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==
"@types/raf@^3.4.0":
version "3.4.0"
resolved "https://registry.yarnpkg.com/@types/raf/-/raf-3.4.0.tgz#2b72cbd55405e071f1c4d29992638e022b20acc2"
integrity sha512-taW5/WYqo36N7V39oYyHP9Ipfd5pNFvGTIQsNGj86xV88YQ7GnI30/yMfKDF7Zgin0m3e+ikX88FvImnK4RjGw==
"@types/range-parser@*":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
@ -3410,12 +3405,12 @@ available-typed-arrays@^1.0.5:
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7"
integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==
axios@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.0.tgz#f1e5292f26b2fd5c2e66876adc5b06cdbd7d2102"
integrity sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==
axios@^1.6.7:
version "1.6.7"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.7.tgz#7b48c2e27c96f9c68a2f8f31e2ab19f59b06b0a7"
integrity sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==
dependencies:
follow-redirects "^1.15.0"
follow-redirects "^1.15.4"
form-data "^4.0.0"
proxy-from-env "^1.1.0"
@ -3645,11 +3640,6 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-arraybuffer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc"
integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==
base64-js@^1.0.2, base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@ -3922,11 +3912,6 @@ browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.16.3, browserslist@^4
node-releases "^2.0.13"
update-browserslist-db "^1.0.11"
btoa@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/btoa/-/btoa-1.2.1.tgz#01a9909f8b2c93f6bf680ba26131eb30f7fa3d73"
integrity sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==
buffer-alloc-unsafe@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
@ -4097,20 +4082,6 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001533.tgz#1180daeb2518b93c82f19b904d1fefcf82197707"
integrity sha512-9aY/b05NKU4Yl2sbcJhn4A7MsGwR1EPfW/nrqsnqVA0Oq50wpmPaGI+R1Z0UKlUl96oxUkGEOILWtOHck0eCWw==
canvg@^3.0.6:
version "3.0.10"
resolved "https://registry.yarnpkg.com/canvg/-/canvg-3.0.10.tgz#8e52a2d088b6ffa23ac78970b2a9eebfae0ef4b3"
integrity sha512-qwR2FRNO9NlzTeKIPIKpnTY6fqwuYSequ8Ru8c0YkYU7U0oW+hLUvWadLvAu1Rl72OMNiFhoLu4f8eUjQ7l/+Q==
dependencies:
"@babel/runtime" "^7.12.5"
"@types/raf" "^3.4.0"
core-js "^3.8.3"
raf "^3.4.1"
regenerator-runtime "^0.13.7"
rgbcolor "^1.0.1"
stackblur-canvas "^2.0.0"
svg-pathdata "^6.0.3"
case-sensitive-paths-webpack-plugin@^2.3.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4"
@ -4586,7 +4557,7 @@ 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.29.1, core-js@^3.6.0, core-js@^3.7.0, core-js@^3.8.3:
core-js@^3.29.1, core-js@^3.7.0, core-js@^3.8.3:
version "3.32.2"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.32.2.tgz#172fb5949ef468f93b4be7841af6ab1f21992db7"
integrity sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==
@ -4723,13 +4694,6 @@ css-declaration-sorter@^6.3.1:
resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz#28beac7c20bad7f1775be3a7129d7eae409a3a71"
integrity sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==
css-line-break@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0"
integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==
dependencies:
utrie "^1.0.2"
css-loader@^6.5.0:
version "6.8.1"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.8.1.tgz#0f8f52699f60f5e679eab4ec0fcd68b8e8a50a88"
@ -5124,11 +5088,6 @@ domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1:
dependencies:
domelementtype "^2.2.0"
dompurify@^2.2.0:
version "2.4.7"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.7.tgz#277adeb40a2c84be2d42a8bcd45f582bfa4d0cfc"
integrity sha512-kxxKlPEDa6Nc5WJi+qRgPbOAbgTpSULL+vI3NUXsZMlkJxTqYI9wg5ZTay2sFrdZRWHPWNi+EdAhcJf81WtoMQ==
domutils@^2.5.2, domutils@^2.8.0:
version "2.8.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135"
@ -5394,11 +5353,6 @@ es-to-primitive@^1.2.1:
is-date-object "^1.0.1"
is-symbol "^1.0.2"
es6-promise@^4.2.5:
version "4.2.8"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
escalade@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
@ -5834,11 +5788,6 @@ fd-slicer@~1.1.0:
dependencies:
pend "~1.2.0"
fflate@^0.4.8:
version "0.4.8"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae"
integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==
figgy-pudding@^3.5.1:
version "3.5.2"
resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e"
@ -6048,10 +5997,10 @@ flush-write-stream@^1.0.0:
inherits "^2.0.3"
readable-stream "^2.3.6"
follow-redirects@^1.0.0, follow-redirects@^1.15.0:
version "1.15.4"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf"
integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==
follow-redirects@^1.0.0, follow-redirects@^1.15.4:
version "1.15.5"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020"
integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==
for-each@^0.3.3:
version "0.3.3"
@ -6666,23 +6615,6 @@ html-webpack-plugin@^5.1.0:
pretty-error "^4.0.0"
tapable "^2.0.0"
html2canvas@^1.0.0, html2canvas@^1.0.0-rc.5:
version "1.4.1"
resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==
dependencies:
css-line-break "^2.1.0"
text-segmentation "^1.0.3"
html2pdf.js@^0.10.1:
version "0.10.1"
resolved "https://registry.yarnpkg.com/html2pdf.js/-/html2pdf.js-0.10.1.tgz#9363910cca52a54113633e552a726722209a8eed"
integrity sha512-3onwwhOWsZfNjIZwV6YIJ6FVhXk+X9YxHSqzeS6hup+1dGi2DHI+zZYUJ+iFnvtaYcjlhyrILL1fvRCUOa8Fcg==
dependencies:
es6-promise "^4.2.5"
html2canvas "^1.0.0"
jspdf "^2.3.1"
htmlparser2@^6.0.0, htmlparser2@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7"
@ -7531,21 +7463,6 @@ jsonpointer@^5.0.0:
resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559"
integrity sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==
jspdf@^2.3.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/jspdf/-/jspdf-2.5.1.tgz#00c85250abf5447a05f3b32ab9935ab4a56592cc"
integrity sha512-hXObxz7ZqoyhxET78+XR34Xu2qFGrJJ2I2bE5w4SM8eFaFEkW2xcGRVUss360fYelwRSid/jT078kbNvmoW0QA==
dependencies:
"@babel/runtime" "^7.14.0"
atob "^2.1.2"
btoa "^1.2.1"
fflate "^0.4.8"
optionalDependencies:
canvg "^3.0.6"
core-js "^3.6.0"
dompurify "^2.2.0"
html2canvas "^1.0.0-rc.5"
keyv@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.0.0.tgz#44923ba39e68b12a7cec7df6c3268c031f2ef373"
@ -8844,11 +8761,6 @@ pend@~1.2.0:
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
performance-now@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==
picocolors@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f"
@ -8886,10 +8798,10 @@ pify@^4.0.1:
resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
pinia@^2.0.30:
version "2.1.6"
resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.1.6.tgz#e88959f14b61c4debd9c42d0c9944e2875cbe0fa"
integrity sha512-bIU6QuE5qZviMmct5XwCesXelb5VavdOWKWaB17ggk++NUwQWWbP5YnsONTk3b752QkW9sACiR81rorpeOMSvQ==
pinia@^2.1.7:
version "2.1.7"
resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.1.7.tgz#4cf5420d9324ca00b7b4984d3fbf693222115bbc"
integrity sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==
dependencies:
"@vue/devtools-api" "^6.5.0"
vue-demi ">=0.14.5"
@ -9419,13 +9331,6 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
raf@^3.4.1:
version "3.4.1"
resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==
dependencies:
performance-now "^2.1.0"
randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
@ -9560,11 +9465,6 @@ regenerator-runtime@^0.11.0:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
regenerator-runtime@^0.13.7:
version "0.13.11"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
regenerator-runtime@^0.14.0:
version "0.14.0"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45"
@ -9723,11 +9623,6 @@ reusify@^1.0.4:
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
rgbcolor@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/rgbcolor/-/rgbcolor-1.0.1.tgz#d6505ecdb304a6595da26fa4b43307306775945d"
integrity sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==
rimraf@^2.5.4, rimraf@^2.6.3:
version "2.7.1"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
@ -10378,11 +10273,6 @@ stable@^0.1.8:
resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==
stackblur-canvas@^2.0.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/stackblur-canvas/-/stackblur-canvas-2.6.0.tgz#7876bab4ea99bfc97b69ce662614d7a1afb2d71b"
integrity sha512-8S1aIA+UoF6erJYnglGPug6MaHYGo1Ot7h5fuXx4fUPvcvQfcdw2o/ppCse63+eZf8PPidSu4v1JnmEVtEDnpg==
stackframe@^1.3.4:
version "1.3.4"
resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.3.4.tgz#b881a004c8c149a5e8efef37d51b16e412943310"
@ -10666,11 +10556,6 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
svg-pathdata@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz#80b0e0283b652ccbafb69ad4f8f73e8d3fbf2cac"
integrity sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==
svg-tags@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764"
@ -10806,13 +10691,6 @@ terser@^5.0.0, terser@^5.10.0, terser@^5.16.8:
commander "^2.20.0"
source-map-support "~0.5.20"
text-segmentation@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943"
integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==
dependencies:
utrie "^1.0.2"
text-table@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
@ -11104,10 +10982,10 @@ typescript@~4.5.5:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3"
integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==
typescript@~5.1.6:
version "5.1.6"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274"
integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==
typescript@~5.3.3:
version "5.3.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
unbox-primitive@^1.0.2:
version "1.0.2"
@ -11290,13 +11168,6 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
utrie@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645"
integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==
dependencies:
base64-arraybuffer "^1.0.2"
uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
@ -11606,10 +11477,10 @@ webpack-bundle-analyzer@^4.4.0:
sirv "^2.0.3"
ws "^7.3.1"
webpack-bundle-tracker@1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/webpack-bundle-tracker/-/webpack-bundle-tracker-1.8.1.tgz#d1cdbd62da622abe1243f099657af86a6ca2656d"
integrity sha512-X1qtXG4ue92gjWQO2VhLVq8HDEf9GzUWE0OQyAQObVEZsFB1SUtSQ7o47agF5WZIaHfJUTKak4jEErU0gzoPcQ==
webpack-bundle-tracker@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webpack-bundle-tracker/-/webpack-bundle-tracker-3.0.1.tgz#dd4809cd22b231b296dfef5634353d875b1502f2"
integrity sha512-q0/19A1gpP74oBC3rgveDBh09D1RGpLvREEOmen9eonTbcuhNAyLkfmfoQeOm+j4k26f+Q2mJSzEXoPu42gBFg==
dependencies:
lodash.assign "^4.2.0"
lodash.defaults "^4.2.0"
@ -11617,7 +11488,6 @@ webpack-bundle-tracker@1.8.1:
lodash.frompairs "^4.0.1"
lodash.get "^4.4.2"
lodash.topairs "^4.3.0"
strip-ansi "^6.0.0"
webpack-chain@^6.5.1:
version "6.5.1"