diff --git a/.idea/dictionaries/vaben.xml b/.idea/dictionaries/vaben.xml index e910c82d..a8b8f4df 100644 --- a/.idea/dictionaries/vaben.xml +++ b/.idea/dictionaries/vaben.xml @@ -1,6 +1,7 @@ + mealplan pinia selfhosted unapplied diff --git a/cookbook/forms.py b/cookbook/forms.py index 401fc9a5..1e5dee36 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -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( diff --git a/cookbook/helper/automation_helper.py b/cookbook/helper/automation_helper.py index a86d405b..fa333fb3 100644 --- a/cookbook/helper/automation_helper.py +++ b/cookbook/helper/automation_helper.py @@ -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 diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index ffcf5bea..c7772a07 100644 --- a/cookbook/helper/shopping_helper.py +++ b/cookbook/helper/shopping_helper.py @@ -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') diff --git a/cookbook/locale/de/LC_MESSAGES/django.po b/cookbook/locale/de/LC_MESSAGES/django.po index ae2a9075..bdcde5e0 100644 --- a/cookbook/locale/de/LC_MESSAGES/django.po +++ b/cookbook/locale/de/LC_MESSAGES/django.po @@ -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 \n" +"PO-Revision-Date: 2024-02-13 16:19+0000\n" +"Last-Translator: Kirstin Seidel-Gebert \n" "Language-Team: German \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" -" Passwort und Token werden im Klartext in der Datenbank " +" Kennwort und Token werden im Klartext 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.
" "\n" " Um das Risiko zu minimieren sollten, wenn möglich, Tokens oder " diff --git a/cookbook/locale/nl/LC_MESSAGES/django.po b/cookbook/locale/nl/LC_MESSAGES/django.po index 0cb0f122..0cc262cd 100644 --- a/cookbook/locale/nl/LC_MESSAGES/django.po +++ b/cookbook/locale/nl/LC_MESSAGES/django.po @@ -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 \n" +"PO-Revision-Date: 2024-02-10 12:20+0000\n" +"Last-Translator: Jonan B \n" "Language-Team: Dutch \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" diff --git a/cookbook/locale/zh_CN/LC_MESSAGES/django.po b/cookbook/locale/zh_CN/LC_MESSAGES/django.po index 3025787a..084689d3 100644 --- a/cookbook/locale/zh_CN/LC_MESSAGES/django.po +++ b/cookbook/locale/zh_CN/LC_MESSAGES/django.po @@ -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: 吕楪 \n" +"PO-Revision-Date: 2024-02-15 03:19+0000\n" +"Last-Translator: dalan \n" "Language-Team: Chinese (Simplified) \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" diff --git a/cookbook/migrations/0159_add_shoppinglistentry_fields.py b/cookbook/migrations/0159_add_shoppinglistentry_fields.py index 9246c9b0..ca7ead35 100644 --- a/cookbook/migrations/0159_add_shoppinglistentry_fields.py +++ b/cookbook/migrations/0159_add_shoppinglistentry_fields.py @@ -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(): diff --git a/cookbook/migrations/0160_delete_shoppinglist_orphans.py b/cookbook/migrations/0160_delete_shoppinglist_orphans.py index 26e08656..6966eae6 100644 --- a/cookbook/migrations/0160_delete_shoppinglist_orphans.py +++ b/cookbook/migrations/0160_delete_shoppinglist_orphans.py @@ -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) diff --git a/cookbook/migrations/0210_shoppinglistentry_updated_at.py b/cookbook/migrations/0210_shoppinglistentry_updated_at.py new file mode 100644 index 00000000..28c2a135 --- /dev/null +++ b/cookbook/migrations/0210_shoppinglistentry_updated_at.py @@ -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), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index da927d50..0c332a19 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -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) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 991e09ac..f3a6532b 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -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',) diff --git a/cookbook/static/themes/tandoor.min.css b/cookbook/static/themes/tandoor.min.css index 5744a8d8..36abfb42 100644 --- a/cookbook/static/themes/tandoor.min.css +++ b/cookbook/static/themes/tandoor.min.css @@ -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 { diff --git a/cookbook/static/themes/tandoor_dark.min.css b/cookbook/static/themes/tandoor_dark.min.css index 68a14037..cd72ad7c 100644 --- a/cookbook/static/themes/tandoor_dark.min.css +++ b/cookbook/static/themes/tandoor_dark.min.css @@ -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) diff --git a/cookbook/templates/account/password_reset.html b/cookbook/templates/account/password_reset.html index 60cfd702..7337440a 100644 --- a/cookbook/templates/account/password_reset.html +++ b/cookbook/templates/account/password_reset.html @@ -34,5 +34,14 @@ +
+
+ {% trans "Sign In" %} + {% if SIGNUP_ENABLED %} + - {% trans "Sign Up" %} + {% endif %} +
+
+ {% endblock %} \ No newline at end of file diff --git a/cookbook/templates/account/password_reset_done.html b/cookbook/templates/account/password_reset_done.html index b756e8ab..aca75783 100644 --- a/cookbook/templates/account/password_reset_done.html +++ b/cookbook/templates/account/password_reset_done.html @@ -7,11 +7,32 @@ {% block title %}{% trans "Password Reset" %}{% endblock %} {% block content %} -

{% trans "Password Reset" %}

+ {% if user.is_authenticated %} - {% include "account/snippets/already_logged_in.html" %} + {% include "account/snippets/already_logged_in.html" %} {% endif %} -

{% blocktrans %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}

+
+
+

{% trans "Password Reset" %}

+
+
+ +
+
+
+

{% blocktrans %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}

+
+
+ +
+
+ {% trans "Sign In" %} + {% if SIGNUP_ENABLED %} + - {% trans "Sign Up" %} + {% endif %} +
+
+ {% endblock %} \ No newline at end of file diff --git a/cookbook/templates/base.html b/cookbook/templates/base.html index f1d6c2f7..a20696a3 100644 --- a/cookbook/templates/base.html +++ b/cookbook/templates/base.html @@ -408,7 +408,7 @@ {% endif %} -
diff --git a/cookbook/templates/recipe_view.html b/cookbook/templates/recipe_view.html index 2ec2b117..c0f62d47 100644 --- a/cookbook/templates/recipe_view.html +++ b/cookbook/templates/recipe_view.html @@ -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 %}, diff --git a/cookbook/templates/shoppinglist_template.html b/cookbook/templates/shoppinglist_template.html index c8c1d3f5..ceb61342 100644 --- a/cookbook/templates/shoppinglist_template.html +++ b/cookbook/templates/shoppinglist_template.html @@ -2,6 +2,7 @@ {% load render_bundle from webpack_loader %} {% load static %} {% load i18n %} + {% block title %} {{ title }} {% endblock %} {% block content_fluid %} @@ -10,16 +11,21 @@
-{% endblock %} {% block script %} {% if debug %} - -{% else %} - -{% endif %} +{% endblock %} - +{% block script %} + {% if debug %} + + {% else %} + + {% endif %} -{% render_bundle 'shopping_list_view' %} {% endblock %} + + + + {% render_bundle 'shopping_list_view' %} +{% endblock %} diff --git a/cookbook/tests/api/test_api_shopping_list_entry.py b/cookbook/tests/api/test_api_shopping_list_entry.py index 0ea9a2d8..06319bf6 100644 --- a/cookbook/tests/api/test_api_shopping_list_entry.py +++ b/cookbook/tests/api/test_api_shopping_list_entry.py @@ -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], diff --git a/cookbook/tests/api/test_api_shopping_list_entryv2.py b/cookbook/tests/api/test_api_shopping_list_entryv2.py index a1e78f7b..f1266d1e 100644 --- a/cookbook/tests/api/test_api_shopping_list_entryv2.py +++ b/cookbook/tests/api/test_api_shopping_list_entryv2.py @@ -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 diff --git a/cookbook/tests/api/test_api_shopping_recipe.py b/cookbook/tests/api/test_api_shopping_recipe.py index 473a631c..5001aca2 100644 --- a/cookbook/tests/api/test_api_shopping_recipe.py +++ b/cookbook/tests/api/test_api_shopping_recipe.py @@ -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'] diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 1a91a33c..2720df72 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -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 - return self.queryset + 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] diff --git a/cookbook/views/views.py b/cookbook/views/views.py index 1c12c77d..6454cf0d 100644 --- a/cookbook/views/views.py +++ b/cookbook/views/views.py @@ -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') diff --git a/docs/features/authentication.md b/docs/features/authentication.md index 1b6f497d..84df9fca 100644 --- a/docs/features/authentication.md +++ b/docs/features/authentication.md @@ -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. diff --git a/docs/system/updating.md b/docs/system/updating.md index 404da607..caaa0417 100644 --- a/docs/system/updating.md +++ b/docs/system/updating.md @@ -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. diff --git a/recipes/settings.py b/recipes/settings.py index f14d2701..ea61ae2f 100644 --- a/recipes/settings.py +++ b/recipes/settings.py @@ -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)) diff --git a/requirements.txt b/requirements.txt index 7f08008a..8fc695c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/vue/package.json b/vue/package.json index 4f56ee31..c4229831 100644 --- a/vue/package.json +++ b/vue/package.json @@ -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": [ diff --git a/vue/src/apps/CookbookView/CookbookView.vue b/vue/src/apps/CookbookView/CookbookView.vue index bc922d7e..9ad465d5 100644 --- a/vue/src/apps/CookbookView/CookbookView.vue +++ b/vue/src/apps/CookbookView/CookbookView.vue @@ -11,50 +11,90 @@ + + oldest to newest + newest to oldest + alphabetical order + manually + + + {{submitText}} +
-
-
-
-
- - - - - -
- {{ book.name }} -
-
{{ book.description }}
-
-
-
-
-
-
+
+
+
+
+
+ + + + + + +
+ {{ book.name }} +
+
{{ book.description }}
+
+
+
+
+
+
+
+
+ + + + + +
- - - - - - +
+
+ + + +
+
+ +
+
+
+ + #{{ index + 1 }} + + {{ book.name }} +
+
+
+
+
+
-
- @@ -49,5 +89,46 @@ export default { diff --git a/vue/src/components/BottomNavigationBar.vue b/vue/src/components/BottomNavigationBar.vue index 23b0a244..71002710 100644 --- a/vue/src/components/BottomNavigationBar.vue +++ b/vue/src/components/BottomNavigationBar.vue @@ -11,14 +11,14 @@
-
{{ $t('Recipes') }}
+
{{ $t('Recipes') }}
-
{{ $t('Meal_Plan') }}
+
{{ $t('Meal_Plan') }}
@@ -53,14 +53,14 @@
-
{{ $t('Shopping_list') }}
+
{{ $t('Shopping_list') }}
-
{{ $t('Books') }}
+
{{ $t('Books') }}
diff --git a/vue/src/components/Buttons/DownloadPDF.vue b/vue/src/components/Buttons/DownloadPDF.vue index fd7560f9..ce9ea626 100644 --- a/vue/src/components/Buttons/DownloadPDF.vue +++ b/vue/src/components/Buttons/DownloadPDF.vue @@ -6,7 +6,6 @@ diff --git a/vue/src/components/NumberScalerComponent.vue b/vue/src/components/NumberScalerComponent.vue new file mode 100644 index 00000000..f9cb5eab --- /dev/null +++ b/vue/src/components/NumberScalerComponent.vue @@ -0,0 +1,74 @@ + + + + + + \ No newline at end of file diff --git a/vue/src/components/RecipeViewComponent.vue b/vue/src/components/RecipeViewComponent.vue index 07a051ad..584d354b 100644 --- a/vue/src/components/RecipeViewComponent.vue +++ b/vue/src/components/RecipeViewComponent.vue @@ -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() - - this.servings = this.servings_cache[this.rootrecipe.id] = this.recipe.servings + 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(() => { diff --git a/vue/src/components/Settings/ShoppingSettingsComponent.vue b/vue/src/components/Settings/ShoppingSettingsComponent.vue index dfa96319..f5a7da82 100644 --- a/vue/src/components/Settings/ShoppingSettingsComponent.vue +++ b/vue/src/components/Settings/ShoppingSettingsComponent.vue @@ -1,10 +1,10 @@ - diff --git a/vue/src/locales/de.json b/vue/src/locales/de.json index 95b0ad67..f1ad1d6e 100644 --- a/vue/src/locales/de.json +++ b/vue/src/locales/de.json @@ -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" } diff --git a/vue/src/locales/en.json b/vue/src/locales/en.json index e085bd92..c6b9b0d0 100644 --- a/vue/src/locales/en.json +++ b/vue/src/locales/en.json @@ -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.", diff --git a/vue/src/locales/pl.json b/vue/src/locales/pl.json index 2febdb60..fda5fe55 100644 --- a/vue/src/locales/pl.json +++ b/vue/src/locales/pl.json @@ -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" } diff --git a/vue/src/locales/zh_Hans.json b/vue/src/locales/zh_Hans.json index e28d0a0c..43692c88 100644 --- a/vue/src/locales/zh_Hans.json +++ b/vue/src/locales/zh_Hans.json @@ -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": "自定义徽标" } diff --git a/vue/src/stores/ShoppingListStore.js b/vue/src/stores/ShoppingListStore.js new file mode 100644 index 00000000..6a4aab1e --- /dev/null +++ b/vue/src/stores/ShoppingListStore.js @@ -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} 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} 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} 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')) + } + } + }, +}) diff --git a/vue/src/stores/UserPreferenceStore.js b/vue/src/stores/UserPreferenceStore.js index dbb3939e..5e50a9ed 100644 --- a/vue/src/stores/UserPreferenceStore.js +++ b/vue/src/stores/UserPreferenceStore.js @@ -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, - }), - getters: { - }, + 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: {}, 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 + }) + } + + }, + 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>} @@ -69,12 +185,16 @@ export const useUserPreferenceStore = defineStore(_STORE_ID, { */ refreshFromAPI() { let apiClient = new ApiApiFactory() - if(!this.currently_updating){ + if (!this.currently_updating) { this.currently_updating = true return apiClient.retrieveUserPreference(localStorage.getItem('USER_ID')).then(r => { 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 diff --git a/vue/src/utils/models.js b/vue/src/utils/models.js index 9b1e544d..8953b76c 100644 --- a/vue/src/utils/models.js +++ b/vue/src/utils/models.js @@ -408,6 +408,7 @@ export class Models { static SHOPPING_CATEGORY = { name: "Shopping_Category", apiName: "SupermarketCategory", + merge: true, create: { params: [["name", "description"]], form: { diff --git a/vue/src/utils/openapi/api.ts b/vue/src/utils/openapi/api.ts index e849d7ab..2c816e12 100644 --- a/vue/src/utils/openapi/api.ts +++ b/vue/src/utils/openapi/api.ts @@ -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} + * @memberof ShoppingListEntryBulk + */ + ids: Array; + /** + * + * @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 => { + 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 => { + listSupermarkets: async (query?: string, options: any = {}): Promise => { 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 => { + listUserFiles: async (query?: string, options: any = {}): Promise => { 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 => { + // 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 => { + 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> { + 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>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listSupermarkets(options); + async listSupermarkets(query?: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + 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>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listUserFiles(options); + async listUserFiles(query?: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + 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> { + 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> { + 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 { + 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> { - return localVarFp.listSupermarkets(options).then((request) => request(axios, basePath)); + listSupermarkets(query?: string, options?: any): AxiosPromise> { + 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> { - return localVarFp.listUserFiles(options).then((request) => request(axios, basePath)); + listUserFiles(query?: string, options?: any): AxiosPromise> { + 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 { 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 { + 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 { 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 { + 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. diff --git a/vue/src/utils/utils.js b/vue/src/utils/utils.js index 00cc3735..515dc242 100644 --- a/vue/src/utils/utils.js +++ b/vue/src/utils/utils.js @@ -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) { diff --git a/vue/yarn.lock b/vue/yarn.lock index 6eb5eb51..cb3efb26 100644 --- a/vue/yarn.lock +++ b/vue/yarn.lock @@ -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"