mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-26 11:49:41 -05:00
Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb224a769d | ||
|
|
4515eba9d7 | ||
|
|
30b37bf0b6 | ||
|
|
f17207e56e | ||
|
|
2cba0e18af | ||
|
|
ec6e81316a | ||
|
|
b72897b222 | ||
|
|
bca1ebbf99 | ||
|
|
f0342d4568 | ||
|
|
81f62de500 | ||
|
|
f783949a61 | ||
|
|
820fad1b5c | ||
|
|
1169abd942 | ||
|
|
48e175f58f | ||
|
|
5450e18342 | ||
|
|
ea590f8e49 | ||
|
|
13626ca11b | ||
|
|
f53fe1e3c4 | ||
|
|
d177316b47 | ||
|
|
338db1fac2 | ||
|
|
377619473c | ||
|
|
000962c5bb | ||
|
|
9228c1d59f | ||
|
|
27007de7a0 | ||
|
|
29c99b66a1 | ||
|
|
bc179f430d | ||
|
|
58c412ad95 | ||
|
|
4f248afe76 | ||
|
|
f722d24eaa | ||
|
|
723b74509f | ||
|
|
ad4b1393dd | ||
|
|
04bab7072c | ||
|
|
6391cee9eb | ||
|
|
14884fc0d4 | ||
|
|
f2191f79dd | ||
|
|
c2533d9ea2 | ||
|
|
db72fdb1bb | ||
|
|
78252662cb | ||
|
|
4e078bf477 | ||
|
|
2e9e226fe0 | ||
|
|
18cfbd80ab | ||
|
|
4d284b4fff | ||
|
|
b1128dd134 | ||
|
|
3aebf58406 | ||
|
|
f3816a77df | ||
|
|
e4183d79ab | ||
|
|
f4aa1a083f | ||
|
|
ed5508b576 | ||
|
|
040e247487 | ||
|
|
5d28c7b17d | ||
|
|
15b2df07f2 | ||
|
|
ed8f97e9e0 | ||
|
|
034f68fc28 | ||
|
|
0158087a0b | ||
|
|
cb6bfd741d | ||
|
|
afeee5f7cb | ||
|
|
b43d6e08d4 | ||
|
|
1188624376 | ||
|
|
9ac837c969 | ||
|
|
fc4b017d30 | ||
|
|
4636ac28f9 | ||
|
|
397912e87f | ||
|
|
d0b860e623 | ||
|
|
8a90ed1274 | ||
|
|
163c2a53b6 | ||
|
|
286d707347 | ||
|
|
98d308aee9 | ||
|
|
a7c5240227 | ||
|
|
75fcff8e70 | ||
|
|
2f27cf4deb | ||
|
|
686b595f45 | ||
|
|
0f9f9e8f7c | ||
|
|
7be7c5b954 | ||
|
|
0853a9ec64 | ||
|
|
fa3daee965 | ||
|
|
aba45657c3 | ||
|
|
e6abdf8cd4 | ||
|
|
6cedde7b2d | ||
|
|
741e9eb370 | ||
|
|
7db523d8c4 | ||
|
|
41f0060c43 | ||
|
|
5572833f64 | ||
|
|
780e441a3b | ||
|
|
c4fd2d0b4e | ||
|
|
1c6618f452 | ||
|
|
8c96a75a1e | ||
|
|
44baa8322c | ||
|
|
0fbb95438a | ||
|
|
c56dd9563c | ||
|
|
0008b7c975 | ||
|
|
524f086cc5 | ||
|
|
8550387e0c | ||
|
|
1618f8df79 | ||
|
|
22dfb2a410 | ||
|
|
f099e2e5d3 | ||
|
|
6973c65142 | ||
|
|
774c05e76f | ||
|
|
b08c39e284 | ||
|
|
ae036cfa9a | ||
|
|
37628c1735 | ||
|
|
530a6db35c | ||
|
|
2930093da0 | ||
|
|
b7e63a466b | ||
|
|
a01f86a14e | ||
|
|
9704268fdc | ||
|
|
84cc4c1165 | ||
|
|
5cb70becb8 | ||
|
|
5f99abf459 | ||
|
|
4a8ddce391 | ||
|
|
9a14a87c27 | ||
|
|
c01634f9bd | ||
|
|
f055df3b4d | ||
|
|
a83f474d70 | ||
|
|
63d358df36 | ||
|
|
e70548fcc0 | ||
|
|
17b03905e6 | ||
|
|
90403e6a13 | ||
|
|
db400cae25 | ||
|
|
0f8eee4e0f | ||
|
|
1f532f6276 | ||
|
|
b32715e493 | ||
|
|
0d19e12118 | ||
|
|
96e5213fa6 | ||
|
|
44c567d20b | ||
|
|
a71564a424 | ||
|
|
8183e350c9 | ||
|
|
9119d773f1 | ||
|
|
27e5955c78 |
@@ -1,12 +1,7 @@
|
||||
FROM python:3.10-alpine3.18
|
||||
FROM python:3.13-alpine3.22
|
||||
|
||||
#Install all dependencies.
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git yarn
|
||||
|
||||
# Fix libxml error from xmlsec https://github.com/xmlsec/python-xmlsec/issues/257#issuecomment-1738620862
|
||||
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v3.15/community/" | tee -a /etc/apk/repositories
|
||||
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v3.15/main" | tee -a /etc/apk/repositories
|
||||
RUN apk add --no-cache libxml2-dev=2.9.14-r2 xmlsec-dev=1.2.33-r0
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git yarn libgcc libstdc++ nginx tini envsubst nodejs npm
|
||||
|
||||
#Print all logs without buffering it.
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
@@ -24,8 +19,10 @@ RUN \
|
||||
if [ `apk --print-arch` = "armv7" ]; then \
|
||||
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
|
||||
fi
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev && \
|
||||
echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \
|
||||
pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt && \
|
||||
rm -rf /tmp/pip-tmp && \
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev xmlsec-dev xmlsec build-base g++ curl rust && \
|
||||
python -m pip install --upgrade pip && \
|
||||
pip debug -v && \
|
||||
pip install wheel==0.45.1 && \
|
||||
pip install setuptools_rust==1.10.2 && \
|
||||
pip install -r /tmp/pip-tmp/requirements.txt --no-cache-dir &&\
|
||||
apk --purge del .build-deps
|
||||
2
.github/workflows/build-docker.yml
vendored
2
.github/workflows/build-docker.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
suffix: ""
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Get version number
|
||||
id: get_version
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -12,8 +12,8 @@ jobs:
|
||||
python-version: ["3.12"]
|
||||
node-version: ["22"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: awalsh128/cache-apt-pkgs-action@v1.5.1
|
||||
- uses: actions/checkout@v5
|
||||
- uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
with:
|
||||
packages: libsasl2-dev python3-dev libxml2-dev libxmlsec1-dev libxslt-dev libxmlsec1-openssl libxslt-dev libldap2-dev libssl-dev gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev xmlsec-dev xmlsec build-base g++ curl
|
||||
version: 1.0
|
||||
|
||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
if: github.repository_owner == 'TandoorRecipes' && ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.x
|
||||
|
||||
62
.vscode/tasks.json
vendored
62
.vscode/tasks.json
vendored
@@ -14,28 +14,16 @@
|
||||
},
|
||||
{
|
||||
"label": "Setup Dev Server",
|
||||
"dependsOn": ["Run Migrations", "Yarn Build"]
|
||||
"dependsOn": ["Run Migrations"]
|
||||
},
|
||||
{
|
||||
"label": "Run Dev Server",
|
||||
"type": "shell",
|
||||
"type": "shell",
|
||||
"dependsOn": ["Setup Dev Server"],
|
||||
"command": "python3 manage.py runserver"
|
||||
"command": "DEBUG=1 python3 manage.py runserver"
|
||||
},
|
||||
{
|
||||
"label": "Yarn Install",
|
||||
"dependsOn": ["Yarn Install - Vue", "Yarn Install - Vue3"]
|
||||
},
|
||||
{
|
||||
"label": "Yarn Install - Vue",
|
||||
"type": "shell",
|
||||
"command": "yarn install --force",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Yarn Install - Vue3",
|
||||
"type": "shell",
|
||||
"command": "yarn install --force",
|
||||
"options": {
|
||||
@@ -44,18 +32,6 @@
|
||||
},
|
||||
{
|
||||
"label": "Generate API",
|
||||
"dependsOn": ["Generate API - Vue", "Generate API - Vue3"]
|
||||
},
|
||||
{
|
||||
"label": "Generate API - Vue",
|
||||
"type": "shell",
|
||||
"command": "openapi-generator-cli generate -g typescript-axios -i http://127.0.0.1:8000/openapi/",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue/src/utils/openapi"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Generate API - Vue3",
|
||||
"type": "shell",
|
||||
"command": "openapi-generator-cli generate -g typescript-fetch -i http://127.0.0.1:8000/openapi/",
|
||||
"options": {
|
||||
@@ -63,43 +39,19 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Yarn Serve",
|
||||
"label": "Yarn Dev",
|
||||
"type": "shell",
|
||||
"command": "yarn serve",
|
||||
"dependsOn": ["Yarn Install - Vue"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Vite Serve",
|
||||
"type": "shell",
|
||||
"command": "vite",
|
||||
"dependsOn": ["Yarn Install - Vue3"],
|
||||
"command": "yarn dev",
|
||||
"dependsOn": ["Yarn Install"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Yarn Build",
|
||||
"dependsOn": ["Yarn Build - Vue", "Vite Build - Vue3"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "Yarn Build - Vue",
|
||||
"type": "shell",
|
||||
"command": "yarn build",
|
||||
"dependsOn": ["Yarn Install - Vue"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue"
|
||||
},
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "Vite Build - Vue3",
|
||||
"type": "shell",
|
||||
"command": "vite build",
|
||||
"dependsOn": ["Yarn Install - Vue3"],
|
||||
"dependsOn": ["Yarn Install"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue3"
|
||||
},
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer"><img src="https://badgen.net/badge/icon/discord?icon=discord&label" ></a>
|
||||
<a href="https://hub.docker.com/r/vabene1111/recipes" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/docker/pulls/vabene1111/recipes" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/releases/latest" rel="noopener noreferrer"><img src="https://img.shields.io/github/v/release/vabene1111/recipes" ></a>
|
||||
<a href="https://app.tandoor.dev/accounts/login/?demo" rel="noopener noreferrer"><img src="https://img.shields.io/badge/demo-available-success" ></a>
|
||||
<a href="https://app.tandoor.dev/e/demo-auto-login/" rel="noopener noreferrer"><img src="https://img.shields.io/badge/demo-available-success" ></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://tandoor.dev" target="_blank" rel="noopener noreferrer">Website</a> •
|
||||
<a href="https://docs.tandoor.dev/install/docker/" target="_blank" rel="noopener noreferrer">Installation</a> •
|
||||
<a href="https://docs.tandoor.dev/" target="_blank" rel="noopener noreferrer">Docs</a> •
|
||||
<a href="https://app.tandoor.dev/accounts/login/?demo" target="_blank" rel="noopener noreferrer">Demo</a> •
|
||||
<a href="https://app.tandoor.dev/e/demo-auto-login/" target="_blank" rel="noopener noreferrer">Demo</a> •
|
||||
<a href="https://community.tandoor.dev" target="_blank" rel="noopener noreferrer">Community</a> •
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer">Discord</a>
|
||||
</p>
|
||||
|
||||
@@ -17,7 +17,7 @@ from .models import (BookmarkletImport, Comment, CookLog, CustomFilter, Food, Im
|
||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
|
||||
TelegramBot, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
|
||||
ViewLog, ConnectorConfig)
|
||||
ViewLog, ConnectorConfig, AiProvider, AiLog)
|
||||
|
||||
admin.site.login = secure_admin_login(admin.site.login)
|
||||
|
||||
@@ -90,6 +90,20 @@ class SearchPreferenceAdmin(admin.ModelAdmin):
|
||||
admin.site.register(SearchPreference, SearchPreferenceAdmin)
|
||||
|
||||
|
||||
class AiProviderAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'space', 'model_name',)
|
||||
search_fields = ('name', 'space', 'model_name',)
|
||||
|
||||
|
||||
admin.site.register(AiProvider, AiProviderAdmin)
|
||||
|
||||
|
||||
class AiLogAdmin(admin.ModelAdmin):
|
||||
list_display = ('ai_provider', 'function', 'credit_cost', 'created_by', 'created_at',)
|
||||
|
||||
admin.site.register(AiLog, AiLogAdmin)
|
||||
|
||||
|
||||
class StorageAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'method')
|
||||
search_fields = ('name',)
|
||||
|
||||
@@ -26,6 +26,7 @@ class ImportExportBase(forms.Form):
|
||||
PAPRIKA = 'PAPRIKA'
|
||||
NEXTCLOUD = 'NEXTCLOUD'
|
||||
MEALIE = 'MEALIE'
|
||||
MEALIE1 = 'MEALIE1'
|
||||
CHOWDOWN = 'CHOWDOWN'
|
||||
SAFFRON = 'SAFFRON'
|
||||
CHEFTAP = 'CHEFTAP'
|
||||
@@ -46,7 +47,7 @@ class ImportExportBase(forms.Form):
|
||||
PDF = 'PDF'
|
||||
GOURMET = 'GOURMET'
|
||||
|
||||
type = forms.ChoiceField(choices=((DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), (MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'),
|
||||
type = forms.ChoiceField(choices=((DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), (MEALIE, 'Mealie'), (MEALIE1, 'Mealie1'), (CHOWDOWN, 'Chowdown'),
|
||||
(SAFFRON, 'Saffron'), (CHEFTAP, 'ChefTap'), (PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'),
|
||||
(DOMESTICA, 'Domestica'), (MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'),
|
||||
(PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), (MELARECIPES, 'Melarecipes'),
|
||||
@@ -75,6 +76,11 @@ class ImportForm(ImportExportBase):
|
||||
files = MultipleFileField(required=True)
|
||||
duplicates = forms.BooleanField(help_text=_('To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'),
|
||||
required=False)
|
||||
meal_plans = forms.BooleanField(required=False)
|
||||
shopping_lists = forms.BooleanField(required=False)
|
||||
nutrition_per_serving = forms.BooleanField(required=False) # some managers (e.g. mealie) do not specify what the nutrition's relate to so we let the user choose
|
||||
|
||||
|
||||
class ExportForm(ImportExportBase):
|
||||
recipes = forms.ModelMultipleChoiceField(widget=MultiSelectWidget, queryset=Recipe.objects.none(), required=False)
|
||||
all = forms.BooleanField(required=False)
|
||||
|
||||
83
cookbook/helper/ai_helper.py
Normal file
83
cookbook/helper/ai_helper.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils import timezone
|
||||
from django.db.models import Sum
|
||||
from litellm import CustomLogger
|
||||
|
||||
from cookbook.models import AiLog
|
||||
from recipes import settings
|
||||
|
||||
|
||||
def get_monthly_token_usage(space):
|
||||
"""
|
||||
returns the number of credits the space has used in the current month
|
||||
"""
|
||||
token_usage = AiLog.objects.filter(space=space, credits_from_balance=False, created_at__month=timezone.now().month).aggregate(Sum('credit_cost'))['credit_cost__sum']
|
||||
if token_usage is None:
|
||||
token_usage = 0
|
||||
return token_usage
|
||||
|
||||
|
||||
def has_monthly_token(space):
|
||||
"""
|
||||
checks if the monthly credit limit has been exceeded
|
||||
"""
|
||||
return get_monthly_token_usage(space) < space.ai_credits_monthly
|
||||
|
||||
|
||||
def can_perform_ai_request(space):
|
||||
return (has_monthly_token(space) or space.ai_credits_balance > 0) and space.ai_enabled
|
||||
|
||||
|
||||
class AiCallbackHandler(CustomLogger):
|
||||
space = None
|
||||
user = None
|
||||
ai_provider = None
|
||||
|
||||
def __init__(self, space, user, ai_provider):
|
||||
super().__init__()
|
||||
self.space = space
|
||||
self.user = user
|
||||
self.ai_provider = ai_provider
|
||||
|
||||
def log_pre_api_call(self, model, messages, kwargs):
|
||||
pass
|
||||
|
||||
def log_post_api_call(self, kwargs, response_obj, start_time, end_time):
|
||||
pass
|
||||
|
||||
def log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self.create_ai_log(kwargs, response_obj, start_time, end_time)
|
||||
|
||||
def log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self.create_ai_log(kwargs, response_obj, start_time, end_time)
|
||||
|
||||
def create_ai_log(self, kwargs, response_obj, start_time, end_time):
|
||||
credit_cost = 0
|
||||
credits_from_balance = False
|
||||
if self.ai_provider.log_credit_cost:
|
||||
credit_cost = kwargs.get("response_cost", 0) * 100
|
||||
|
||||
if (not has_monthly_token(self.space)) and self.space.ai_credits_balance > 0:
|
||||
remaining_balance = self.space.ai_credits_balance - Decimal(str(credit_cost))
|
||||
if remaining_balance < 0:
|
||||
remaining_balance = 0
|
||||
if settings.HOSTED and self.space.ai_credits_monthly == 0:
|
||||
self.space.ai_enabled = False
|
||||
|
||||
self.space.ai_credits_balance = remaining_balance
|
||||
credits_from_balance = True
|
||||
self.space.save()
|
||||
|
||||
AiLog.objects.create(
|
||||
created_by=self.user,
|
||||
space=self.space,
|
||||
ai_provider=self.ai_provider,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
input_tokens=response_obj['usage']['prompt_tokens'],
|
||||
output_tokens=response_obj['usage']['completion_tokens'],
|
||||
function=AiLog.F_FILE_IMPORT,
|
||||
credit_cost=credit_cost,
|
||||
credits_from_balance=credits_from_balance,
|
||||
)
|
||||
22
cookbook/helper/batch_edit_helper.py
Normal file
22
cookbook/helper/batch_edit_helper.py
Normal file
@@ -0,0 +1,22 @@
|
||||
def add_to_relation(relation_model, base_field_name, base_ids, related_field_name, related_ids):
|
||||
"""
|
||||
given a model, the base and related field and the base and related ids, bulk create relation objects
|
||||
"""
|
||||
relation_objects = []
|
||||
for b in base_ids:
|
||||
for r in related_ids:
|
||||
relation_objects.append(relation_model(**{base_field_name: b, related_field_name: r}))
|
||||
relation_model.objects.bulk_create(relation_objects, ignore_conflicts=True, unique_fields=(base_field_name, related_field_name,))
|
||||
|
||||
|
||||
def remove_from_relation(relation_model, base_field_name, base_ids, related_field_name, related_ids):
|
||||
relation_model.objects.filter(**{f'{base_field_name}__in': base_ids, f'{related_field_name}__in': related_ids}).delete()
|
||||
|
||||
|
||||
def remove_all_from_relation(relation_model, base_field_name, base_ids):
|
||||
relation_model.objects.filter(**{f'{base_field_name}__in': base_ids}).delete()
|
||||
|
||||
|
||||
def set_relation(relation_model, base_field_name, base_ids, related_field_name, related_ids):
|
||||
remove_all_from_relation(relation_model, base_field_name, base_ids)
|
||||
add_to_relation(relation_model, base_field_name, base_ids, related_field_name, related_ids)
|
||||
@@ -37,7 +37,7 @@ def get_filetype(name):
|
||||
|
||||
def is_file_type_allowed(filename, image_only=False):
|
||||
is_file_allowed = False
|
||||
allowed_file_types = ['.pdf', '.docx', '.xlsx', '.css']
|
||||
allowed_file_types = ['.pdf', '.docx', '.xlsx', '.css', '.mp4', '.mov']
|
||||
allowed_image_types = ['.png', '.jpg', '.jpeg', '.gif', '.webp']
|
||||
check_list = allowed_image_types
|
||||
if not image_only:
|
||||
@@ -77,6 +77,8 @@ def handle_image(request, image_object, filetype):
|
||||
file_format = 'JPEG'
|
||||
if filetype == '.png':
|
||||
file_format = 'PNG'
|
||||
if filetype == '.webp':
|
||||
file_format = 'WEBP'
|
||||
|
||||
if (image_object.size / 1000) > 500: # if larger than 500 kb compress
|
||||
if filetype == '.jpeg' or filetype == '.jpg':
|
||||
|
||||
@@ -3,17 +3,19 @@ import inspect
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope, TokenHasScope
|
||||
from oauth2_provider.models import AccessToken
|
||||
from rest_framework import permissions
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
|
||||
from cookbook.models import Recipe, ShareLink, UserSpace
|
||||
import random
|
||||
from cookbook.models import Recipe, ShareLink, UserSpace, Space
|
||||
|
||||
|
||||
def get_allowed_groups(groups_required):
|
||||
@@ -331,6 +333,25 @@ class CustomRecipePermission(permissions.BasePermission):
|
||||
or has_group_permission(request.user, ['user'])) and obj.space == request.space
|
||||
|
||||
|
||||
class CustomAiProviderPermission(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission class for the AiProvider api endpoint
|
||||
users: can read all
|
||||
admins: can read and write
|
||||
superusers: can read and write + write providers without a space
|
||||
"""
|
||||
message = _('You do not have the required permissions to view this page!')
|
||||
|
||||
def has_permission(self, request, view): # user is either at least a user and the request is safe
|
||||
return (has_group_permission(request.user, ['user']) and request.method in SAFE_METHODS) or (has_group_permission(request.user, ['admin']) or request.user.is_superuser)
|
||||
|
||||
# editing of global providers allowed for superusers, space providers by admins and users can read only access
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return ((obj.space is None and request.user.is_superuser)
|
||||
or (obj.space == request.space and has_group_permission(request.user, ['admin']))
|
||||
or (obj.space == request.space and has_group_permission(request.user, ['user']) and request.method in SAFE_METHODS))
|
||||
|
||||
|
||||
class CustomUserPermission(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission class for user api endpoint
|
||||
@@ -437,3 +458,36 @@ class IsReadOnlyDRF(permissions.BasePermission):
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.method in SAFE_METHODS
|
||||
|
||||
|
||||
class IsCreateDRF(permissions.BasePermission):
|
||||
message = 'You cannot interact with this object, you can only create'
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.method == 'POST'
|
||||
|
||||
|
||||
def create_space_for_user(user, name=None):
|
||||
with scopes_disabled():
|
||||
if not name:
|
||||
name = f"{user.username}'s Space"
|
||||
|
||||
if Space.objects.filter(name=name).exists():
|
||||
name = f'{name} #{random.randrange(1, 10 ** 5)}'
|
||||
|
||||
created_space = Space(name=name,
|
||||
created_by=user,
|
||||
max_file_storage_mb=settings.SPACE_DEFAULT_MAX_FILES,
|
||||
max_recipes=settings.SPACE_DEFAULT_MAX_RECIPES,
|
||||
max_users=settings.SPACE_DEFAULT_MAX_USERS,
|
||||
allow_sharing=settings.SPACE_DEFAULT_ALLOW_SHARING,
|
||||
ai_enabled=settings.SPACE_AI_ENABLED,
|
||||
ai_credits_monthly=settings.SPACE_AI_CREDITS_MONTHLY,
|
||||
space_setup_completed=False, )
|
||||
created_space.save()
|
||||
|
||||
UserSpace.objects.filter(user=user).update(active=False)
|
||||
user_space = UserSpace.objects.create(space=created_space, user=user, active=True)
|
||||
user_space.groups.add(Group.objects.filter(name='admin').get())
|
||||
|
||||
return user_space
|
||||
|
||||
@@ -319,10 +319,10 @@ def clean_instruction_string(instruction):
|
||||
.replace("", _('reverse rotation')) \
|
||||
.replace("", _('careful rotation')) \
|
||||
.replace("", _('knead')) \
|
||||
.replace("Andicken ", _('thicken')) \
|
||||
.replace("Erwärmen ", _('warm up')) \
|
||||
.replace("Fermentieren ", _('ferment')) \
|
||||
.replace("Sous-vide ", _("sous-vide"))
|
||||
.replace("", _('thicken')) \
|
||||
.replace("", _('warm up')) \
|
||||
.replace("", _('ferment')) \
|
||||
.replace("", _("sous-vide"))
|
||||
|
||||
|
||||
def parse_instructions(instructions):
|
||||
@@ -403,6 +403,8 @@ def parse_servings_text(servings):
|
||||
|
||||
|
||||
def parse_time(recipe_time):
|
||||
if not recipe_time:
|
||||
return 0
|
||||
if type(recipe_time) not in [int, float]:
|
||||
try:
|
||||
recipe_time = float(re.search(r'\d+', recipe_time).group())
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
from django.contrib.auth.models import Group
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from psycopg2.errors import UniqueViolation
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
import random
|
||||
|
||||
from cookbook.helper.permission_helper import create_space_for_user
|
||||
from cookbook.models import Space, UserSpace
|
||||
from cookbook.views import views
|
||||
from recipes import settings
|
||||
|
||||
@@ -34,16 +41,28 @@ class ScopeMiddleware:
|
||||
if request.path.startswith(prefix + '/switch-space/'):
|
||||
return self.get_response(request)
|
||||
|
||||
with scopes_disabled():
|
||||
if request.user.userspace_set.count() == 0 and not reverse('account_logout') in request.path:
|
||||
return views.space_overview(request)
|
||||
if request.path.startswith(prefix + '/invite/'):
|
||||
return self.get_response(request)
|
||||
|
||||
# get active user space, if for some reason more than one space is active select first (group permission checks will fail, this is not intended at this point)
|
||||
user_space = request.user.userspace_set.filter(active=True).first()
|
||||
|
||||
if not user_space:
|
||||
return views.space_overview(request)
|
||||
if not user_space and request.user.userspace_set.count() > 0:
|
||||
# if the users has a userspace but nothing is active, activate the first one
|
||||
user_space = request.user.userspace_set.first()
|
||||
if user_space:
|
||||
user_space.active = True
|
||||
user_space.save()
|
||||
|
||||
if not user_space:
|
||||
if 'signup_token' in request.session:
|
||||
# if user is authenticated, has no space but a signup token (InviteLink) is present, redirect to invite link logic
|
||||
return HttpResponseRedirect(reverse('view_invite', args=[request.session.pop('signup_token', '')]))
|
||||
else:
|
||||
# if user does not yet have a space create one for him
|
||||
user_space = create_space_for_user(request.user)
|
||||
|
||||
# TODO remove the need for this view
|
||||
if user_space.groups.count() == 0 and not reverse('account_logout') in request.path:
|
||||
return views.no_groups(request)
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ class Integration:
|
||||
files = None
|
||||
export_type = None
|
||||
ignored_recipes = []
|
||||
import_log = None
|
||||
import_duplicates = False
|
||||
|
||||
import_meal_plans = True
|
||||
import_shopping_lists = True
|
||||
nutrition_per_serving = False
|
||||
|
||||
def __init__(self, request, export_type):
|
||||
"""
|
||||
@@ -102,7 +108,7 @@ class Integration:
|
||||
"""
|
||||
return True
|
||||
|
||||
def do_import(self, files, il, import_duplicates):
|
||||
def do_import(self, files, il, import_duplicates, meal_plans=True, shopping_lists=True, nutrition_per_serving=False):
|
||||
"""
|
||||
Imports given files
|
||||
:param import_duplicates: if true duplicates are imported as well
|
||||
@@ -111,6 +117,12 @@ class Integration:
|
||||
:return: HttpResponseRedirect to the recipe search showing all imported recipes
|
||||
"""
|
||||
with scope(space=self.request.space):
|
||||
self.import_log = il
|
||||
self.import_duplicates = import_duplicates
|
||||
|
||||
self.import_meal_plans = meal_plans
|
||||
self.import_shopping_lists = shopping_lists
|
||||
self.nutrition_per_serving = nutrition_per_serving
|
||||
|
||||
try:
|
||||
self.files = files
|
||||
@@ -166,20 +178,24 @@ class Integration:
|
||||
il.total_recipes = len(new_file_list)
|
||||
file_list = new_file_list
|
||||
|
||||
for z in file_list:
|
||||
try:
|
||||
if not hasattr(z, 'filename') or isinstance(z, Tag):
|
||||
recipe = self.get_recipe_from_file(z)
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self.handle_exception(e, log=il, message=f'-------------------- \nERROR \n{e}\n--------------------\n')
|
||||
if isinstance(self, cookbook.integration.mealie1.Mealie1):
|
||||
# since the mealie 1.0 export is a backup and not a classic recipe export we treat it a bit differently
|
||||
recipes = self.get_recipe_from_file(import_zip)
|
||||
else:
|
||||
for z in file_list:
|
||||
try:
|
||||
if not hasattr(z, 'filename') or isinstance(z, Tag):
|
||||
recipe = self.get_recipe_from_file(z)
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self.handle_exception(e, log=il, message=f'-------------------- \nERROR \n{e}\n--------------------\n')
|
||||
import_zip.close()
|
||||
elif '.json' in f['name'] or '.xml' in f['name'] or '.txt' in f['name'] or '.mmf' in f['name'] or '.rk' in f['name'] or '.melarecipe' in f['name']:
|
||||
data_list = self.split_recipe_file(f['file'])
|
||||
|
||||
342
cookbook/integration/mealie1.py
Normal file
342
cookbook/integration/mealie1.py
Normal file
@@ -0,0 +1,342 @@
|
||||
import json
|
||||
import re
|
||||
import traceback
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
from gettext import gettext as _
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from cookbook.helper import ingredient_parser
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step, Food, Unit, SupermarketCategory, PropertyType, Property, MealType, MealPlan, CookLog, ShoppingListEntry
|
||||
|
||||
|
||||
class Mealie1(Integration):
|
||||
"""
|
||||
integration for mealie past version 1.0
|
||||
"""
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
mealie_database = json.loads(BytesIO(file.read('database.json')).getvalue().decode("utf-8"))
|
||||
self.import_log.total_recipes = len(mealie_database['recipes'])
|
||||
self.import_log.msg += f"Importing {len(mealie_database["categories"]) + len(mealie_database["tags"])} tags and categories as keywords...\n"
|
||||
self.import_log.save()
|
||||
|
||||
keywords_categories_dict = {}
|
||||
for c in mealie_database['categories']:
|
||||
if keyword := Keyword.objects.filter(name=c['name'], space=self.request.space).first():
|
||||
keywords_categories_dict[c['id']] = keyword.pk
|
||||
else:
|
||||
keyword = Keyword.objects.create(name=c['name'], space=self.request.space)
|
||||
keywords_categories_dict[c['id']] = keyword.pk
|
||||
|
||||
keywords_tags_dict = {}
|
||||
for t in mealie_database['tags']:
|
||||
if keyword := Keyword.objects.filter(name=t['name'], space=self.request.space).first():
|
||||
keywords_tags_dict[t['id']] = keyword.pk
|
||||
else:
|
||||
keyword = Keyword.objects.create(name=t['name'], space=self.request.space)
|
||||
keywords_tags_dict[t['id']] = keyword.pk
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["multi_purpose_labels"])} multi purpose labels as supermarket categories...\n"
|
||||
self.import_log.save()
|
||||
|
||||
supermarket_categories_dict = {}
|
||||
for m in mealie_database['multi_purpose_labels']:
|
||||
if supermarket_category := SupermarketCategory.objects.filter(name=m['name'], space=self.request.space).first():
|
||||
supermarket_categories_dict[m['id']] = supermarket_category.pk
|
||||
else:
|
||||
supermarket_category = SupermarketCategory.objects.create(name=m['name'], space=self.request.space)
|
||||
supermarket_categories_dict[m['id']] = supermarket_category.pk
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["ingredient_foods"])} foods...\n"
|
||||
self.import_log.save()
|
||||
|
||||
foods_dict = {}
|
||||
for f in mealie_database['ingredient_foods']:
|
||||
if food := Food.objects.filter(name=f['name'], space=self.request.space).first():
|
||||
foods_dict[f['id']] = food.pk
|
||||
else:
|
||||
food = {'name': f['name'],
|
||||
'plural_name': f['plural_name'],
|
||||
'description': f['description'],
|
||||
'space': self.request.space}
|
||||
|
||||
if f['label_id'] and f['label_id'] in supermarket_categories_dict:
|
||||
food['supermarket_category_id'] = supermarket_categories_dict[f['label_id']]
|
||||
|
||||
food = Food.objects.create(**food)
|
||||
if f['on_hand']:
|
||||
food.onhand_users.add(self.request.user)
|
||||
foods_dict[f['id']] = food.pk
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["ingredient_units"])} units...\n"
|
||||
self.import_log.save()
|
||||
|
||||
units_dict = {}
|
||||
for u in mealie_database['ingredient_units']:
|
||||
if unit := Unit.objects.filter(name=u['name'], space=self.request.space).first():
|
||||
units_dict[u['id']] = unit.pk
|
||||
else:
|
||||
unit = Unit.objects.create(name=u['name'], plural_name=u['plural_name'], description=u['description'], space=self.request.space)
|
||||
units_dict[u['id']] = unit.pk
|
||||
|
||||
recipes_dict = {}
|
||||
recipe_property_factor_dict = {}
|
||||
recipes = []
|
||||
recipe_keyword_relation = []
|
||||
for r in mealie_database['recipes']:
|
||||
if Recipe.objects.filter(space=self.request.space, name=r['name']).exists() and not self.import_duplicates:
|
||||
self.import_log.msg += f"Ignoring {r['name']} because a recipe with this name already exists.\n"
|
||||
self.import_log.save()
|
||||
else:
|
||||
recipe = Recipe.objects.create(
|
||||
waiting_time=parse_time(r['perform_time']),
|
||||
working_time=parse_time(r['prep_time']),
|
||||
description=r['description'][:512],
|
||||
name=r['name'],
|
||||
source_url=r['org_url'],
|
||||
servings=r['recipe_servings'] if r['recipe_servings'] and r['recipe_servings'] != 0 else 1,
|
||||
servings_text=r['recipe_yield'].strip() if r['recipe_yield'] else "",
|
||||
internal=True,
|
||||
created_at=r['created_at'],
|
||||
space=self.request.space,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
||||
if not self.nutrition_per_serving:
|
||||
recipe_property_factor_dict[r['id']] = recipe.servings
|
||||
|
||||
self.import_log.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.import_log.imported_recipes += 1
|
||||
self.import_log.save()
|
||||
|
||||
recipes.append(recipe)
|
||||
recipes_dict[r['id']] = recipe.pk
|
||||
recipe_keyword_relation.append(Recipe.keywords.through(recipe_id=recipe.pk, keyword_id=self.keyword.pk))
|
||||
|
||||
Recipe.keywords.through.objects.bulk_create(recipe_keyword_relation, ignore_conflicts=True)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipe_instructions"])} instructions...\n"
|
||||
self.import_log.save()
|
||||
|
||||
steps_relation = []
|
||||
first_step_of_recipe_dict = {}
|
||||
for s in mealie_database['recipe_instructions']:
|
||||
if s['recipe_id'] in recipes_dict:
|
||||
step = Step.objects.create(instruction=(s['text'] if s['text'] else "") + (f" \n {s['summary']}" if s['summary'] else ""),
|
||||
order=s['position'],
|
||||
name=s['title'],
|
||||
space=self.request.space)
|
||||
steps_relation.append(Recipe.steps.through(recipe_id=recipes_dict[s['recipe_id']], step_id=step.pk))
|
||||
if s['recipe_id'] not in first_step_of_recipe_dict:
|
||||
first_step_of_recipe_dict[s['recipe_id']] = step.pk
|
||||
|
||||
for n in mealie_database['notes']:
|
||||
if n['recipe_id'] in recipes_dict:
|
||||
step = Step.objects.create(instruction=n['text'],
|
||||
name=n['title'],
|
||||
order=100,
|
||||
space=self.request.space)
|
||||
steps_relation.append(Recipe.steps.through(recipe_id=recipes_dict[n['recipe_id']], step_id=step.pk))
|
||||
|
||||
Recipe.steps.through.objects.bulk_create(steps_relation)
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipes_ingredients"])} ingredients...\n"
|
||||
self.import_log.save()
|
||||
|
||||
ingredients_relation = []
|
||||
for i in mealie_database['recipes_ingredients']:
|
||||
if i['recipe_id'] in recipes_dict:
|
||||
if i['title']:
|
||||
title_ingredient = Ingredient.objects.create(
|
||||
note=i['title'],
|
||||
is_header=True,
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=title_ingredient.pk))
|
||||
if i['food_id']:
|
||||
ingredient = Ingredient.objects.create(
|
||||
food_id=foods_dict[i['food_id']] if i['food_id'] in foods_dict else None,
|
||||
unit_id=units_dict[i['unit_id']] if i['unit_id'] in units_dict else None,
|
||||
original_text=i['original_text'],
|
||||
order=i['position'],
|
||||
amount=i['quantity'],
|
||||
note=i['note'],
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=ingredient.pk))
|
||||
elif i['note'].strip():
|
||||
amount, unit, food, note = ingredient_parser.parse(i['note'].strip())
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
ingredient = Ingredient.objects.create(
|
||||
food=f,
|
||||
unit=u,
|
||||
amount=amount,
|
||||
note=note,
|
||||
original_text=i['original_text'],
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=ingredient.pk))
|
||||
Step.ingredients.through.objects.bulk_create(ingredients_relation)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipes_to_categories"]) + len(mealie_database["recipes_to_tags"])} category and keyword relations...\n"
|
||||
self.import_log.save()
|
||||
|
||||
recipe_keyword_relation = []
|
||||
for rC in mealie_database['recipes_to_categories']:
|
||||
if rC['recipe_id'] in recipes_dict:
|
||||
recipe_keyword_relation.append(Recipe.keywords.through(recipe_id=recipes_dict[rC['recipe_id']], keyword_id=keywords_categories_dict[rC['category_id']]))
|
||||
|
||||
for rT in mealie_database['recipes_to_tags']:
|
||||
if rT['recipe_id'] in recipes_dict:
|
||||
recipe_keyword_relation.append(Recipe.keywords.through(recipe_id=recipes_dict[rT['recipe_id']], keyword_id=keywords_tags_dict[rT['tag_id']]))
|
||||
|
||||
Recipe.keywords.through.objects.bulk_create(recipe_keyword_relation, ignore_conflicts=True)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipe_nutrition"])} properties...\n"
|
||||
self.import_log.save()
|
||||
|
||||
property_types_dict = {
|
||||
'calories': PropertyType.objects.get_or_create(name=_('Calories'), space=self.request.space, defaults={'unit': 'kcal', 'fdc_id': 1008})[0],
|
||||
'carbohydrate_content': PropertyType.objects.get_or_create(name=_('Carbohydrates'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1005})[0],
|
||||
'cholesterol_content': PropertyType.objects.get_or_create(name=_('Cholesterol'), space=self.request.space, defaults={'unit': 'mg', 'fdc_id': 1253})[0],
|
||||
'fat_content': PropertyType.objects.get_or_create(name=_('Fat'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1004})[0],
|
||||
'fiber_content': PropertyType.objects.get_or_create(name=_('Fiber'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1079})[0],
|
||||
'protein_content': PropertyType.objects.get_or_create(name=_('Protein'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1003})[0],
|
||||
'saturated_fat_content': PropertyType.objects.get_or_create(name=_('Saturated Fat'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1258})[0],
|
||||
'sodium_content': PropertyType.objects.get_or_create(name=_('Sodium'), space=self.request.space, defaults={'unit': 'mg', 'fdc_id': 1093})[0],
|
||||
'sugar_content': PropertyType.objects.get_or_create(name=_('Sugar'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1063})[0],
|
||||
'trans_fat_content': PropertyType.objects.get_or_create(name=_('Trans Fat'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1257})[0],
|
||||
'unsaturated_fat_content': PropertyType.objects.get_or_create(name=_('Unsaturated Fat'), space=self.request.space, defaults={'unit': 'g'})[0],
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
recipe_properties_relation = []
|
||||
properties_relation = []
|
||||
for r in mealie_database['recipe_nutrition']:
|
||||
if r['recipe_id'] in recipes_dict:
|
||||
for key in property_types_dict:
|
||||
if r[key]:
|
||||
properties_relation.append(
|
||||
Property(property_type_id=property_types_dict[key].pk,
|
||||
property_amount=Decimal(str(r[key])) / (
|
||||
Decimal(str(recipe_property_factor_dict[r['recipe_id']])) if r['recipe_id'] in recipe_property_factor_dict else 1),
|
||||
open_data_food_slug=r['recipe_id'],
|
||||
space=self.request.space))
|
||||
properties = Property.objects.bulk_create(properties_relation)
|
||||
property_ids = []
|
||||
for p in properties:
|
||||
recipe_properties_relation.append(Recipe.properties.through(recipe_id=recipes_dict[p.open_data_food_slug], property_id=p.pk))
|
||||
property_ids.append(p.pk)
|
||||
Recipe.properties.through.objects.bulk_create(recipe_properties_relation, ignore_conflicts=True)
|
||||
Property.objects.filter(id__in=property_ids).update(open_data_food_slug=None)
|
||||
|
||||
# delete unused property types
|
||||
for pT in property_types_dict:
|
||||
try:
|
||||
property_types_dict[pT].delete()
|
||||
except:
|
||||
pass
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipe_comments"]) + len(mealie_database["recipe_timeline_events"])} comments and cook logs...\n"
|
||||
self.import_log.save()
|
||||
|
||||
cook_log_list = []
|
||||
for c in mealie_database['recipe_comments']:
|
||||
if c['recipe_id'] in recipes_dict:
|
||||
cook_log_list.append(CookLog(
|
||||
recipe_id=recipes_dict[c['recipe_id']],
|
||||
comment=c['text'],
|
||||
created_at=c['created_at'],
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
|
||||
for c in mealie_database['recipe_timeline_events']:
|
||||
if c['recipe_id'] in recipes_dict:
|
||||
if c['event_type'] == 'comment':
|
||||
cook_log_list.append(CookLog(
|
||||
recipe_id=recipes_dict[c['recipe_id']],
|
||||
comment=c['message'],
|
||||
created_at=c['created_at'],
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
|
||||
CookLog.objects.bulk_create(cook_log_list)
|
||||
|
||||
if self.import_meal_plans:
|
||||
self.import_log.msg += f"Importing {len(mealie_database["group_meal_plans"])} meal plans...\n"
|
||||
self.import_log.save()
|
||||
|
||||
meal_types_dict = {}
|
||||
meal_plans = []
|
||||
for m in mealie_database['group_meal_plans']:
|
||||
if m['recipe_id'] in recipes_dict:
|
||||
if not m['entry_type'] in meal_types_dict:
|
||||
meal_type = MealType.objects.get_or_create(name=m['entry_type'], created_by=self.request.user, space=self.request.space)[0]
|
||||
meal_types_dict[m['entry_type']] = meal_type.pk
|
||||
meal_plans.append(MealPlan(
|
||||
recipe_id=recipes_dict[m['recipe_id']] if m['recipe_id'] else None,
|
||||
title=m['title'] if m['title'] else "",
|
||||
note=m['text'] if m['text'] else "",
|
||||
from_date=m['date'],
|
||||
to_date=m['date'],
|
||||
meal_type_id=meal_types_dict[m['entry_type']],
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
|
||||
MealPlan.objects.bulk_create(meal_plans)
|
||||
|
||||
if self.import_shopping_lists:
|
||||
self.import_log.msg += f"Importing {len(mealie_database["shopping_list_items"])} shopping list items...\n"
|
||||
self.import_log.save()
|
||||
|
||||
shopping_list_items = []
|
||||
for sli in mealie_database['shopping_list_items']:
|
||||
if not sli['checked']:
|
||||
if sli['food_id']:
|
||||
shopping_list_items.append(ShoppingListEntry(
|
||||
amount=sli['quantity'],
|
||||
unit_id=units_dict[sli['unit_id']] if sli['unit_id'] else None,
|
||||
food_id=foods_dict[sli['food_id']] if sli['food_id'] else None,
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
elif not sli['food_id'] and sli['note'].strip():
|
||||
amount, unit, food, note = ingredient_parser.parse(sli['note'].strip())
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
shopping_list_items.append(ShoppingListEntry(
|
||||
amount=amount,
|
||||
unit=u,
|
||||
food=f,
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
ShoppingListEntry.objects.bulk_create(shopping_list_items)
|
||||
|
||||
self.import_log.msg += f"Importing Images. This might take some time ...\n"
|
||||
self.import_log.save()
|
||||
for r in mealie_database['recipes']:
|
||||
try:
|
||||
if recipe := Recipe.objects.filter(pk=recipes_dict[r['id']]).first():
|
||||
self.import_recipe_image(recipe, BytesIO(file.read(f'data/recipes/{str(uuid.UUID(str(r['id'])))}/images/original.webp')), filetype='.webp')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return recipes
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
File diff suppressed because it is too large
Load Diff
34
cookbook/migrations/0223_auto_20250831_1111.py
Normal file
34
cookbook/migrations/0223_auto_20250831_1111.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 4.2.22 on 2025-08-31 09:11
|
||||
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_comments(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
Comment = apps.get_model('cookbook', 'Comment')
|
||||
CookLog = apps.get_model('cookbook', 'CookLog')
|
||||
|
||||
cook_logs = []
|
||||
|
||||
for c in Comment.objects.all():
|
||||
cook_logs.append(CookLog(
|
||||
recipe=c.recipe,
|
||||
created_by=c.created_by,
|
||||
created_at=c.created_at,
|
||||
comment=c.text,
|
||||
space=c.recipe.space,
|
||||
))
|
||||
|
||||
CookLog.objects.bulk_create(cook_logs, unique_fields=('recipe', 'comment', 'created_at', 'created_by'))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0222_alter_shoppinglistrecipe_created_by_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_comments),
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-05 06:51
|
||||
|
||||
import cookbook.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0223_auto_20250831_1111'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='ai_credits_balance',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='ai_credits_monthly',
|
||||
field=models.IntegerField(default=100),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AiProvider',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=128)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('api_key', models.CharField(max_length=2048)),
|
||||
('model_name', models.CharField(max_length=256)),
|
||||
('url', models.CharField(blank=True, max_length=2048, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('space', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AiLog',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('function', models.CharField(max_length=64)),
|
||||
('credit_cost', models.DecimalField(decimal_places=4, max_digits=16)),
|
||||
('credits_from_balance', models.BooleanField(default=False)),
|
||||
('input_tokens', models.IntegerField(default=0)),
|
||||
('output_tokens', models.IntegerField(default=0)),
|
||||
('start_time', models.DateTimeField(null=True)),
|
||||
('end_time', models.DateTimeField(null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('ai_provider', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.aiprovider')),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
|
||||
],
|
||||
bases=(models.Model, cookbook.models.PermissionModelMixin),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0225_space_ai_enabled.py
Normal file
18
cookbook/migrations/0225_space_ai_enabled.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-08 19:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0224_space_ai_credits_balance_space_ai_credits_monthly_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='ai_enabled',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-08 20:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0225_space_ai_enabled'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aiprovider',
|
||||
name='log_credit_cost',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='space',
|
||||
name='ai_credits_monthly',
|
||||
field=models.IntegerField(default=10000),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-09 11:40
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0226_aiprovider_log_credit_cost_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='ai_default_provider',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_ai_default_provider', to='cookbook.aiprovider'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='space',
|
||||
name='ai_credits_balance',
|
||||
field=models.DecimalField(decimal_places=4, default=0, max_digits=16),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0228_space_space_setup_completed.py
Normal file
18
cookbook/migrations/0228_space_space_setup_completed.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-10 20:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0001_squashed_0227_space_ai_default_provider_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='space_setup_completed',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -329,6 +329,13 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
demo = models.BooleanField(default=False)
|
||||
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
|
||||
|
||||
space_setup_completed = models.BooleanField(default=True)
|
||||
|
||||
ai_enabled = models.BooleanField(default=True)
|
||||
ai_credits_monthly = models.IntegerField(default=100)
|
||||
ai_credits_balance = models.DecimalField(default=0, max_digits=16, decimal_places=4)
|
||||
ai_default_provider = models.ForeignKey("AiProvider", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_ai_default_provider')
|
||||
|
||||
internal_note = models.TextField(blank=True, null=True)
|
||||
|
||||
def safe_delete(self):
|
||||
@@ -341,6 +348,9 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
BookmarkletImport.objects.filter(space=self).delete()
|
||||
CustomFilter.objects.filter(space=self).delete()
|
||||
|
||||
AiLog.objects.filter(space=self).delete()
|
||||
AiProvider.objects.filter(space=self).delete()
|
||||
|
||||
Property.objects.filter(space=self).delete()
|
||||
PropertyType.objects.filter(space=self).delete()
|
||||
|
||||
@@ -393,6 +403,47 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
return self.name
|
||||
|
||||
|
||||
class AiProvider(models.Model):
|
||||
name = models.CharField(max_length=128)
|
||||
description = models.TextField(blank=True)
|
||||
# AiProviders can be global, so space=null is allowed (configurable by superusers)
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
|
||||
|
||||
api_key = models.CharField(max_length=2048)
|
||||
model_name = models.CharField(max_length=256)
|
||||
url = models.CharField(max_length=2048, blank=True, null=True)
|
||||
log_credit_cost = models.BooleanField(default=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class AiLog(models.Model, PermissionModelMixin):
|
||||
F_FILE_IMPORT = 'FILE_IMPORT'
|
||||
|
||||
ai_provider = models.ForeignKey(AiProvider, on_delete=models.SET_NULL, null=True)
|
||||
function = models.CharField(max_length=64)
|
||||
credit_cost = models.DecimalField(max_digits=16, decimal_places=4)
|
||||
# if credits from balance were used, else its from monthly quota
|
||||
credits_from_balance = models.BooleanField(default=False)
|
||||
|
||||
input_tokens = models.IntegerField(default=0)
|
||||
output_tokens = models.IntegerField(default=0)
|
||||
start_time = models.DateTimeField(null=True)
|
||||
end_time = models.DateTimeField(null=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.function} {self.ai_provider.name} {self.created_at}"
|
||||
|
||||
|
||||
class ConnectorConfig(models.Model, PermissionModelMixin):
|
||||
HOMEASSISTANT = 'HomeAssistant'
|
||||
CONNECTER_TYPE = ((HOMEASSISTANT, 'HomeAssistant'),)
|
||||
|
||||
@@ -24,8 +24,9 @@ from rest_framework.fields import IntegerField
|
||||
|
||||
from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage
|
||||
from cookbook.helper.HelperFunctions import str2bool
|
||||
from cookbook.helper.ai_helper import get_monthly_token_usage
|
||||
from cookbook.helper.image_processing import is_file_type_allowed
|
||||
from cookbook.helper.permission_helper import above_space_limit
|
||||
from cookbook.helper.permission_helper import above_space_limit, create_space_for_user
|
||||
from cookbook.helper.property_helper import FoodPropertyHelper
|
||||
from cookbook.helper.shopping_helper import RecipeShoppingEditor
|
||||
from cookbook.helper.unit_conversion_helper import UnitConversionHelper
|
||||
@@ -36,7 +37,7 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Cu
|
||||
ShareLink, ShoppingListEntry, ShoppingListRecipe, Space,
|
||||
Step, Storage, Supermarket, SupermarketCategory,
|
||||
SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
|
||||
UserFile, UserPreference, UserSpace, ViewLog, ConnectorConfig, SearchPreference, SearchFields)
|
||||
UserFile, UserPreference, UserSpace, ViewLog, ConnectorConfig, SearchPreference, SearchFields, AiLog, AiProvider)
|
||||
from cookbook.templatetags.custom_tags import markdown
|
||||
from recipes.settings import AWS_ENABLED, MEDIA_URL, EMAIL_HOST
|
||||
|
||||
@@ -325,12 +326,59 @@ class UserFileViewSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ('id', 'file', 'file_download', 'file_size_kb', 'preview', 'created_by', 'created_at')
|
||||
|
||||
|
||||
class AiProviderSerializer(serializers.ModelSerializer):
|
||||
api_key = serializers.CharField(required=False, write_only=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data = self.handle_global_space_logic(validated_data)
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data = self.handle_global_space_logic(validated_data, instance=instance)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def handle_global_space_logic(self, validated_data, instance=None):
|
||||
"""
|
||||
allow superusers to create AI providers without a space but make sure everyone else only uses their own space
|
||||
"""
|
||||
if ('space' not in validated_data or not validated_data['space']) and self.context['request'].user.is_superuser:
|
||||
validated_data['space'] = None
|
||||
else:
|
||||
if instance:
|
||||
validated_data['space'] = instance.space
|
||||
else:
|
||||
validated_data['space'] = self.context['request'].space
|
||||
|
||||
if 'log_credit_cost' in validated_data and not self.context['request'].user.is_superuser:
|
||||
del validated_data['log_credit_cost']
|
||||
|
||||
return validated_data
|
||||
|
||||
class Meta:
|
||||
model = AiProvider
|
||||
fields = ('id', 'name', 'description', 'api_key', 'model_name', 'url', 'log_credit_cost', 'space', 'created_at', 'updated_at')
|
||||
read_only_fields = ('created_at', 'updated_at',)
|
||||
|
||||
|
||||
class AiLogSerializer(serializers.ModelSerializer):
|
||||
ai_provider = AiProviderSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = AiLog
|
||||
fields = ('id', 'ai_provider', 'function', 'credit_cost', 'credits_from_balance', 'input_tokens', 'output_tokens', 'start_time', 'end_time', 'created_by', 'created_at',
|
||||
'updated_at')
|
||||
read_only_fields = ('__all__',)
|
||||
|
||||
|
||||
class SpaceSerializer(WritableNestedModelSerializer):
|
||||
created_by = UserSerializer(read_only=True)
|
||||
user_count = serializers.SerializerMethodField('get_user_count')
|
||||
recipe_count = serializers.SerializerMethodField('get_recipe_count')
|
||||
file_size_mb = serializers.SerializerMethodField('get_file_size_mb')
|
||||
food_inherit = FoodInheritFieldSerializer(many=True)
|
||||
user_count = serializers.SerializerMethodField('get_user_count', read_only=True)
|
||||
recipe_count = serializers.SerializerMethodField('get_recipe_count', read_only=True)
|
||||
file_size_mb = serializers.SerializerMethodField('get_file_size_mb', read_only=True)
|
||||
ai_monthly_credits_used = serializers.SerializerMethodField('get_ai_monthly_credits_used', read_only=True)
|
||||
ai_default_provider = AiProviderSerializer(required=False, allow_null=True)
|
||||
food_inherit = FoodInheritFieldSerializer(many=True, required=False)
|
||||
image = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
nav_logo = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
custom_space_theme = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
@@ -350,6 +398,10 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
||||
def get_recipe_count(self, obj):
|
||||
return Recipe.objects.filter(space=obj).count()
|
||||
|
||||
@extend_schema_field(int)
|
||||
def get_ai_monthly_credits_used(self, obj):
|
||||
return get_monthly_token_usage(obj)
|
||||
|
||||
@extend_schema_field(float)
|
||||
def get_file_size_mb(self, obj):
|
||||
try:
|
||||
@@ -358,7 +410,36 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
||||
return 0
|
||||
|
||||
def create(self, validated_data):
|
||||
raise ValidationError('Cannot create using this endpoint')
|
||||
if Space.objects.filter(created_by=self.context['request'].user).count() >= self.context['request'].user.userpreference.max_owned_spaces:
|
||||
raise serializers.ValidationError(
|
||||
_('You have the reached the maximum amount of spaces that can be owned by you.') + f' ({self.context['request'].user.userpreference.max_owned_spaces})')
|
||||
|
||||
name = None
|
||||
if 'name' in validated_data:
|
||||
name = validated_data['name']
|
||||
user_space = create_space_for_user(self.context['request'].user, name)
|
||||
return user_space.space
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data = self.filter_superuser_parameters(validated_data)
|
||||
|
||||
if 'name' in validated_data:
|
||||
if Space.objects.filter(Q(name=validated_data['name']), ~Q(pk=instance.pk)).exists():
|
||||
raise ValidationError(_('Space Name must be unique.'))
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def filter_superuser_parameters(self, validated_data):
|
||||
if 'ai_enabled' in validated_data and not self.context['request'].user.is_superuser:
|
||||
del validated_data['ai_enabled']
|
||||
|
||||
if 'ai_credits_monthly' in validated_data and not self.context['request'].user.is_superuser:
|
||||
del validated_data['ai_credits_monthly']
|
||||
|
||||
if 'ai_credits_balance' in validated_data and not self.context['request'].user.is_superuser:
|
||||
del validated_data['ai_credits_balance']
|
||||
|
||||
return validated_data
|
||||
|
||||
class Meta:
|
||||
model = Space
|
||||
@@ -366,10 +447,11 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
||||
'id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users',
|
||||
'allow_sharing', 'demo', 'food_inherit', 'user_count', 'recipe_count', 'file_size_mb',
|
||||
'image', 'nav_logo', 'space_theme', 'custom_space_theme', 'nav_bg_color', 'nav_text_color',
|
||||
'logo_color_32', 'logo_color_128', 'logo_color_144', 'logo_color_180', 'logo_color_192', 'logo_color_512', 'logo_color_svg',)
|
||||
'logo_color_32', 'logo_color_128', 'logo_color_144', 'logo_color_180', 'logo_color_192', 'logo_color_512', 'logo_color_svg', 'ai_credits_monthly',
|
||||
'ai_credits_balance', 'ai_monthly_credits_used', 'ai_enabled', 'ai_default_provider', 'space_setup_completed')
|
||||
read_only_fields = (
|
||||
'id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing',
|
||||
'demo',)
|
||||
'demo', 'ai_monthly_credits_used')
|
||||
|
||||
|
||||
class UserSpaceSerializer(WritableNestedModelSerializer):
|
||||
@@ -787,7 +869,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
if plural_name := validated_data.pop('plural_name', None):
|
||||
plural_name = plural_name.strip()
|
||||
|
||||
if food := Food.objects.filter(Q(name=name) | Q(plural_name=name)).first():
|
||||
if food := Food.objects.filter(Q(name__iexact=name) | Q(plural_name__iexact=name)).first():
|
||||
return food
|
||||
|
||||
space = validated_data.pop('space', self.context['request'].space)
|
||||
@@ -1038,7 +1120,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
|
||||
fields = (
|
||||
'id', 'name', 'description', 'image', 'keywords', 'working_time',
|
||||
'waiting_time', 'created_by', 'created_at', 'updated_at',
|
||||
'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent'
|
||||
'internal', 'private', 'servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent'
|
||||
)
|
||||
# TODO having these readonly fields makes "RecipeOverview.ts" (API Client) not generate the RecipeOverviewToJSON second else block which leads to errors when using the api
|
||||
# TODO find a solution (custom schema?) to have these fields readonly (to save performance) and generate a proper client (two serializers would probably do the trick)
|
||||
@@ -1134,6 +1216,35 @@ class RecipeBatchUpdateSerializer(serializers.Serializer):
|
||||
clear_description = serializers.BooleanField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class FoodBatchUpdateSerializer(serializers.Serializer):
|
||||
foods = serializers.ListField(child=serializers.IntegerField())
|
||||
|
||||
category = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
substitute_add = serializers.ListField(child=serializers.IntegerField())
|
||||
substitute_remove = serializers.ListField(child=serializers.IntegerField())
|
||||
substitute_set = serializers.ListField(child=serializers.IntegerField())
|
||||
substitute_remove_all = serializers.BooleanField(default=False)
|
||||
|
||||
inherit_fields_add = serializers.ListField(child=serializers.IntegerField())
|
||||
inherit_fields_remove = serializers.ListField(child=serializers.IntegerField())
|
||||
inherit_fields_set = serializers.ListField(child=serializers.IntegerField())
|
||||
inherit_fields_remove_all = serializers.BooleanField(default=False)
|
||||
|
||||
child_inherit_fields_add = serializers.ListField(child=serializers.IntegerField())
|
||||
child_inherit_fields_remove = serializers.ListField(child=serializers.IntegerField())
|
||||
child_inherit_fields_set = serializers.ListField(child=serializers.IntegerField())
|
||||
child_inherit_fields_remove_all = serializers.BooleanField(default=False)
|
||||
|
||||
substitute_children = serializers.BooleanField(required=False, allow_null=True)
|
||||
substitute_siblings = serializers.BooleanField(required=False, allow_null=True)
|
||||
ignore_shopping = serializers.BooleanField(required=False, allow_null=True)
|
||||
on_hand = serializers.BooleanField(required=False, allow_null=True)
|
||||
|
||||
parent_remove = serializers.BooleanField(required=False, allow_null=True)
|
||||
parent_set = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class CustomFilterSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
shared = UserSerializer(many=True, required=False)
|
||||
|
||||
@@ -1245,8 +1356,8 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
|
||||
|
||||
class AutoMealPlanSerializer(serializers.Serializer):
|
||||
start_date = serializers.DateField()
|
||||
end_date = serializers.DateField()
|
||||
start_date = serializers.DateTimeField()
|
||||
end_date = serializers.DateTimeField()
|
||||
meal_type_id = serializers.IntegerField()
|
||||
keyword_ids = serializers.ListField()
|
||||
servings = CustomDecimalField()
|
||||
@@ -1564,7 +1675,6 @@ class ServerSettingsSerializer(serializers.Serializer):
|
||||
# TODO add all other relevant settings including path/url related ones?
|
||||
shopping_min_autosync_interval = serializers.CharField()
|
||||
enable_pdf_export = serializers.BooleanField()
|
||||
enable_ai_import = serializers.BooleanField()
|
||||
disable_external_connectors = serializers.BooleanField()
|
||||
terms_url = serializers.CharField()
|
||||
privacy_url = serializers.CharField()
|
||||
@@ -1605,6 +1715,11 @@ class FdcQuerySerializer(serializers.Serializer):
|
||||
foods = FdcQueryFoodsSerializer(many=True)
|
||||
|
||||
|
||||
class GenericModelSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
model = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
|
||||
# Export/Import Serializers
|
||||
|
||||
class KeywordExportSerializer(KeywordSerializer):
|
||||
@@ -1788,6 +1903,7 @@ class RecipeFromSourceResponseSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class AiImportSerializer(serializers.Serializer):
|
||||
ai_provider_id = serializers.IntegerField()
|
||||
file = serializers.FileField(allow_null=True)
|
||||
text = serializers.CharField(allow_null=True, allow_blank=True)
|
||||
recipe_id = serializers.CharField(allow_null=True, allow_blank=True)
|
||||
|
||||
@@ -51,11 +51,6 @@
|
||||
{# {% endif %}#}
|
||||
<p class="card-text"><small
|
||||
class="text-muted">{% trans 'Owner' %}: {{ us.space.created_by }}</small>
|
||||
{% if us.space.created_by != us.user %}
|
||||
<p class="card-text"><small
|
||||
class="text-muted"><a
|
||||
href="{% url 'delete_user_space' us.pk %}">{% trans 'Leave Space' %}</a></small>
|
||||
{% endif %}
|
||||
<!--TODO add direct link to management page -->
|
||||
</p>
|
||||
</div>
|
||||
|
||||
168
cookbook/tests/api/test_api_ai_provider.py
Normal file
168
cookbook/tests/api/test_api_ai_provider.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import MealType, PropertyType, AiProvider
|
||||
|
||||
LIST_URL = 'api:aiprovider-list'
|
||||
DETAIL_URL = 'api:aiprovider-detail'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_1(space_1, a1_s1):
|
||||
return AiProvider.objects.get_or_create(name='test_1', space=space_1)[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def obj_2(space_1, a1_s1):
|
||||
return AiProvider.objects.get_or_create(name='test_2', space=None)[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 200],
|
||||
])
|
||||
def test_list_permission(arg, request):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
assert c.get(reverse(LIST_URL)).status_code == arg[1]
|
||||
|
||||
|
||||
def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
|
||||
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 2
|
||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 1
|
||||
|
||||
obj_1.space = space_2
|
||||
obj_1.save()
|
||||
|
||||
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 1
|
||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2
|
||||
|
||||
obj_1.space = None
|
||||
obj_1.save()
|
||||
|
||||
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 2
|
||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 403],
|
||||
['a1_s1', 200],
|
||||
['g1_s2', 403],
|
||||
['u1_s2', 403],
|
||||
['a1_s2', 404],
|
||||
])
|
||||
def test_update(arg, request, obj_1):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.patch(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
),
|
||||
{'name': 'new'},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 200:
|
||||
assert response['name'] == 'new'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 403],
|
||||
['a1_s1', 403],
|
||||
['g1_s2', 403],
|
||||
['u1_s2', 403],
|
||||
['a1_s2', 403],
|
||||
['s1_s1', 200],
|
||||
])
|
||||
def test_update_global(arg, request, obj_2):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.patch(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_2.id}
|
||||
),
|
||||
{'name': 'new'},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 200:
|
||||
assert response['name'] == 'new'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 403],
|
||||
['a1_s1', 201],
|
||||
])
|
||||
def test_add(arg, request, u1_s2):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.post(
|
||||
reverse(LIST_URL),
|
||||
{'name': 'test', 'api_key': 'test', 'model_name': 'test'},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 201:
|
||||
assert response['name'] == 'test'
|
||||
r = c.get(reverse(DETAIL_URL, args={response['id']}))
|
||||
assert r.status_code == 200
|
||||
r = u1_s2.get(reverse(DETAIL_URL, args={response['id']}))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_delete(a1_s1, a1_s2, obj_1):
|
||||
# admins cannot delete foreign space providers
|
||||
r = a1_s2.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
)
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
# admins can delete their space providers
|
||||
r = a1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
)
|
||||
)
|
||||
|
||||
assert r.status_code == 204
|
||||
with scopes_disabled():
|
||||
assert AiProvider.objects.count() == 0
|
||||
|
||||
|
||||
def test_delete_global(a1_s1, s1_s1, obj_2):
|
||||
# admins cant delete global providers
|
||||
r = a1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_2.id}
|
||||
)
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# superusers can delete global providers
|
||||
r = s1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_2.id}
|
||||
)
|
||||
)
|
||||
|
||||
assert r.status_code == 204
|
||||
with scopes_disabled():
|
||||
assert AiProvider.objects.count() == 0
|
||||
@@ -7,6 +7,7 @@ from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import UserSpace
|
||||
from recipes import settings
|
||||
|
||||
LIST_URL = 'api:space-list'
|
||||
DETAIL_URL = 'api:space-detail'
|
||||
@@ -45,7 +46,6 @@ def test_list_multiple(u1_s1, space_1, space_2):
|
||||
assert u1_response['id'] == space_1.id
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
@@ -70,9 +70,9 @@ def test_update(arg, request, space_1, a1_s1):
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 403],
|
||||
['a1_s1', 405],
|
||||
['g1_s1', 201],
|
||||
['u1_s1', 201],
|
||||
['a1_s1', 201],
|
||||
])
|
||||
def test_add(arg, request, u1_s2):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
@@ -90,3 +90,59 @@ def test_delete(u1_s1, u1_s2, a1_s1, space_1):
|
||||
# event the space owner cannot delete his space over the api (this might change later but for now it's only available in the UI)
|
||||
r = a1_s1.delete(reverse(DETAIL_URL, args={space_1.id}))
|
||||
assert r.status_code == 405
|
||||
|
||||
|
||||
def test_superuser_parameters(space_1, a1_s1, s1_s1):
|
||||
# ------- test as normal user -------
|
||||
response = a1_s1.post(reverse(LIST_URL), {'name': 'test', 'ai_enabled': not settings.SPACE_AI_ENABLED, 'ai_credits_monthly': settings.SPACE_AI_CREDITS_MONTHLY + 100, 'ai_credits_balance': 100},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 201
|
||||
response = json.loads(response.content)
|
||||
assert response['ai_enabled'] == settings.SPACE_AI_ENABLED
|
||||
assert response['ai_credits_monthly'] == settings.SPACE_AI_CREDITS_MONTHLY
|
||||
assert response['ai_credits_balance'] == 0
|
||||
|
||||
space_1.created_by = auth.get_user(a1_s1)
|
||||
space_1.ai_enabled = False
|
||||
space_1.ai_credits_monthly = 0
|
||||
space_1.ai_credits_balance = 0
|
||||
space_1.save()
|
||||
|
||||
response = a1_s1.patch(reverse(DETAIL_URL, args={space_1.id}), {'ai_enabled': True, 'ai_credits_monthly': 100, 'ai_credits_balance': 100},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
space_1.refresh_from_db()
|
||||
assert space_1.ai_enabled == False
|
||||
assert space_1.ai_credits_monthly == 0
|
||||
assert space_1.ai_credits_balance == 0
|
||||
|
||||
# ------- test as superuser -------
|
||||
|
||||
response = s1_s1.post(reverse(LIST_URL),
|
||||
{'name': 'test', 'ai_enabled': not settings.SPACE_AI_ENABLED, 'ai_credits_monthly': settings.SPACE_AI_CREDITS_MONTHLY + 100, 'ai_credits_balance': 100},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 201
|
||||
response = json.loads(response.content)
|
||||
assert response['ai_enabled'] == settings.SPACE_AI_ENABLED
|
||||
assert response['ai_credits_monthly'] == settings.SPACE_AI_CREDITS_MONTHLY
|
||||
assert response['ai_credits_balance'] == 0
|
||||
|
||||
space_1.created_by = auth.get_user(s1_s1)
|
||||
space_1.ai_enabled = False
|
||||
space_1.ai_credits_monthly = 0
|
||||
space_1.ai_credits_balance = 0
|
||||
space_1.save()
|
||||
|
||||
response = s1_s1.patch(reverse(DETAIL_URL, args={space_1.id}), {'ai_enabled': True, 'ai_credits_monthly': 100, 'ai_credits_balance': 100},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
space_1.refresh_from_db()
|
||||
assert space_1.ai_enabled == True
|
||||
assert space_1.ai_credits_monthly == 100
|
||||
assert space_1.ai_credits_balance == 100
|
||||
|
||||
@@ -5,6 +5,8 @@ from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import UserSpace
|
||||
|
||||
LIST_URL = 'api:userspace-list'
|
||||
DETAIL_URL = 'api:userspace-detail'
|
||||
|
||||
@@ -13,10 +15,10 @@ DETAIL_URL = 'api:userspace-detail'
|
||||
['a_u', 403, 0],
|
||||
['g1_s1', 200, 1], # sees only own user space
|
||||
['u1_s1', 200, 1],
|
||||
['a1_s1', 200, 3], # sees user space of all users in space
|
||||
['a2_s1', 200, 1],
|
||||
['a1_s1', 200, 4], # admins can see all other members
|
||||
['a2_s1', 200, 4],
|
||||
])
|
||||
def test_list_permission(arg, request, space_1, g1_s1, u1_s1, a1_s1):
|
||||
def test_list_permission(arg, request, space_1, g1_s1, u1_s1, a1_s1, a2_s1):
|
||||
space_1.created_by = auth.get_user(a1_s1)
|
||||
space_1.save()
|
||||
|
||||
@@ -27,6 +29,18 @@ def test_list_permission(arg, request, space_1, g1_s1, u1_s1, a1_s1):
|
||||
assert len(json.loads(result.content)['results']) == arg[2]
|
||||
|
||||
|
||||
def test_list_all_personal(space_2, u1_s1):
|
||||
result = u1_s1.get(reverse('api:userspace-all-personal'))
|
||||
assert result.status_code == 200
|
||||
assert len(json.loads(result.content)) == 1
|
||||
|
||||
UserSpace.objects.create(user=auth.get_user(u1_s1), space=space_2)
|
||||
|
||||
result = u1_s1.get(reverse('api:userspace-all-personal'))
|
||||
assert result.status_code == 200
|
||||
assert len(json.loads(result.content)) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
|
||||
@@ -298,3 +298,11 @@ def a1_s2(client, space_2):
|
||||
@pytest.fixture()
|
||||
def a2_s2(client, space_2):
|
||||
return create_user(client, space_2, group='admin')
|
||||
|
||||
@pytest.fixture()
|
||||
def s1_s1(client, space_1):
|
||||
client = create_user(client, space_1, group='admin')
|
||||
user = auth.get_user(client)
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
return client
|
||||
|
||||
@@ -61,6 +61,8 @@ router.register(r'search-preference', api.SearchPreferenceViewSet)
|
||||
router.register(r'user-space', api.UserSpaceViewSet)
|
||||
router.register(r'view-log', api.ViewLogViewSet)
|
||||
router.register(r'access-token', api.AccessTokenViewSet)
|
||||
router.register(r'ai-provider', api.AiProviderViewSet)
|
||||
router.register(r'ai-log', api.AiLogViewSet)
|
||||
|
||||
router.register(r'localization', api.LocalizationViewSet, basename='localization')
|
||||
router.register(r'server-settings', api.ServerSettingsViewSet, basename='server-settings')
|
||||
@@ -76,10 +78,11 @@ urlpatterns = [
|
||||
|
||||
path('setup/', views.setup, name='view_setup'),
|
||||
path('no-group/', views.no_groups, name='view_no_group'),
|
||||
path('space-overview/', views.space_overview, name='view_space_overview'),
|
||||
path('switch-space/<int:space_id>', views.switch_space, name='view_switch_space'),
|
||||
path('no-perm/', views.no_perm, name='view_no_perm'),
|
||||
#path('space-overview/', views.space_overview, name='view_space_overview'),
|
||||
#path('switch-space/<int:space_id>', views.switch_space, name='view_switch_space'),
|
||||
#path('no-perm/', views.no_perm, name='view_no_perm'),
|
||||
path('invite/<slug:token>', views.invite_link, name='view_invite'),
|
||||
path('invite/<slug:token>/', views.invite_link, name='view_invite'),
|
||||
|
||||
path('system/', views.system, name='view_system'),
|
||||
path('plugin/update/', views.plugin_update, name='view_plugin_update'),
|
||||
|
||||
@@ -18,15 +18,16 @@ import litellm
|
||||
import redis
|
||||
import requests
|
||||
from PIL import UnidentifiedImageError
|
||||
from PIL.ImImagePlugin import number
|
||||
from PIL.features import check
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin.utils import get_deleted_objects, NestedObjects
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.core.cache import caches
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
from django.core.files import File
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When
|
||||
from django.db.models.deletion import Collector
|
||||
from django.db.models.fields.related import ForeignObjectRel
|
||||
from django.db.models.functions import Coalesce, Lower
|
||||
from django.db.models.signals import post_save
|
||||
@@ -35,7 +36,6 @@ from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.utils.datetime_safe import date
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
@@ -65,6 +65,8 @@ from cookbook.connectors.connector_manager import ConnectorManager, ActionType
|
||||
from cookbook.forms import ImportForm, ImportExportBase
|
||||
from cookbook.helper import recipe_url_import as helper
|
||||
from cookbook.helper.HelperFunctions import str2bool, validate_import_url
|
||||
from cookbook.helper.ai_helper import has_monthly_token, can_perform_ai_request, AiCallbackHandler
|
||||
from cookbook.helper.batch_edit_helper import add_to_relation, remove_from_relation, remove_all_from_relation, set_relation
|
||||
from cookbook.helper.image_processing import handle_image
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.open_data_importer import OpenDataImporter
|
||||
@@ -74,7 +76,7 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner, Cus
|
||||
CustomTokenHasScope, CustomUserPermission, IsReadOnlyDRF,
|
||||
above_space_limit,
|
||||
group_required, has_group_permission, is_space_owner,
|
||||
switch_user_active_space
|
||||
switch_user_active_space, CustomAiProviderPermission, IsCreateDRF
|
||||
)
|
||||
from cookbook.helper.recipe_search import RecipeSearch
|
||||
from cookbook.helper.recipe_url_import import clean_dict, get_from_youtube_scraper, get_images_from_soup
|
||||
@@ -85,7 +87,7 @@ from cookbook.models import (Automation, BookmarkletImport, ConnectorConfig, Coo
|
||||
RecipeBookEntry, ShareLink, ShoppingListEntry,
|
||||
ShoppingListRecipe, Space, Step, Storage, Supermarket, SupermarketCategory,
|
||||
SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
|
||||
UserFile, UserPreference, UserSpace, ViewLog, RecipeImport, SearchPreference, SearchFields
|
||||
UserFile, UserPreference, UserSpace, ViewLog, RecipeImport, SearchPreference, SearchFields, AiLog, AiProvider
|
||||
)
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.local import Local
|
||||
@@ -110,12 +112,13 @@ from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer, Au
|
||||
UserSerializer, UserSpaceSerializer, ViewLogSerializer,
|
||||
LocalizationSerializer, ServerSettingsSerializer, RecipeFromSourceResponseSerializer, ShoppingListEntryBulkCreateSerializer, FdcQuerySerializer,
|
||||
AiImportSerializer, ImportOpenDataSerializer, ImportOpenDataMetaDataSerializer, ImportOpenDataResponseSerializer, ExportRequestSerializer,
|
||||
RecipeImportSerializer, ConnectorConfigSerializer, SearchPreferenceSerializer, SearchFieldsSerializer, RecipeBatchUpdateSerializer
|
||||
RecipeImportSerializer, ConnectorConfigSerializer, SearchPreferenceSerializer, SearchFieldsSerializer, RecipeBatchUpdateSerializer,
|
||||
AiProviderSerializer, AiLogSerializer, FoodBatchUpdateSerializer, GenericModelSerializer
|
||||
)
|
||||
from cookbook.version_info import TANDOOR_VERSION
|
||||
from cookbook.views.import_export import get_integration
|
||||
from recipes import settings
|
||||
from recipes.settings import DRF_THROTTLE_RECIPE_URL_IMPORT, FDC_API_KEY, AI_RATELIMIT, AI_API_KEY, AI_MODEL_NAME
|
||||
from recipes.settings import DRF_THROTTLE_RECIPE_URL_IMPORT, FDC_API_KEY, AI_RATELIMIT
|
||||
|
||||
DateExample = OpenApiExample('Date Format', value='1972-12-05', request_only=True)
|
||||
BeforeDateExample = OpenApiExample('Before Date Format', value='-1972-12-05', request_only=True)
|
||||
@@ -131,7 +134,7 @@ class LoggingMixin(object):
|
||||
|
||||
if settings.REDIS_HOST:
|
||||
try:
|
||||
d = date.today().isoformat()
|
||||
d = timezone.now().isoformat()
|
||||
space = request.space
|
||||
endpoint = request.resolver_match.url_name
|
||||
|
||||
@@ -179,7 +182,10 @@ class StandardFilterModelViewSet(viewsets.ModelViewSet):
|
||||
queryset = self.queryset
|
||||
query = self.request.query_params.get('query', None)
|
||||
if query is not None:
|
||||
queryset = queryset.filter(name__icontains=query)
|
||||
try:
|
||||
queryset = queryset.filter(name__icontains=query)
|
||||
except FieldError:
|
||||
pass
|
||||
|
||||
updated_at = self.request.query_params.get('updated_at', None)
|
||||
if updated_at is not None:
|
||||
@@ -454,13 +460,11 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
|
||||
return self.annotate_recipe(queryset=super().get_queryset(), request=self.request,
|
||||
serializer=self.serializer_class, tree=True)
|
||||
|
||||
|
||||
self.queryset = self.queryset.filter(space=self.request.space)
|
||||
# only order if not root_tree or tree mde because in these modes the sorting is relevant for the client
|
||||
if not root_tree and not tree:
|
||||
self.queryset = self.queryset.order_by(Lower('name').asc())
|
||||
|
||||
|
||||
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class,
|
||||
tree=True)
|
||||
|
||||
@@ -543,9 +547,9 @@ class GroupViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
class SpaceViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
queryset = Space.objects
|
||||
serializer_class = SpaceSerializer
|
||||
permission_classes = [IsReadOnlyDRF & CustomIsGuest | CustomIsOwner & CustomIsAdmin & CustomTokenHasReadWriteScope]
|
||||
permission_classes = [((IsReadOnlyDRF | IsCreateDRF) & CustomIsGuest) | CustomIsOwner & CustomIsAdmin & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
http_method_names = ['get', 'patch']
|
||||
http_method_names = ['get', 'post', 'put', 'patch']
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(
|
||||
@@ -564,7 +568,7 @@ class SpaceViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
class UserSpaceViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
queryset = UserSpace.objects
|
||||
serializer_class = UserSpaceSerializer
|
||||
permission_classes = [(CustomIsSpaceOwner | CustomIsOwnerReadOnly) & CustomTokenHasReadWriteScope]
|
||||
permission_classes = [(CustomIsSpaceOwner | (IsReadOnlyDRF & CustomIsUser) | CustomIsOwnerReadOnly) & CustomTokenHasReadWriteScope]
|
||||
http_method_names = ['get', 'put', 'patch', 'delete']
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
@@ -578,10 +582,23 @@ class UserSpaceViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
if internal_note is not None:
|
||||
self.queryset = self.queryset.filter(internal_note=internal_note)
|
||||
|
||||
if is_space_owner(self.request.user, self.request.space):
|
||||
# >= admins can see all users, guest/user can only see themselves
|
||||
if has_group_permission(self.request.user, ['admin']):
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
else:
|
||||
return self.queryset.filter(user=self.request.user, space=self.request.space)
|
||||
return self.queryset.filter(space=self.request.space, user=self.request.user)
|
||||
|
||||
@extend_schema(responses=UserSpaceSerializer(many=True))
|
||||
@decorators.action(detail=False, pagination_class=DefaultPagination, methods=['GET'], serializer_class=UserSpaceSerializer, )
|
||||
def all_personal(self, request):
|
||||
"""
|
||||
return all userspaces for the user requesting the endpoint
|
||||
:param request:
|
||||
:return:
|
||||
"""
|
||||
with scopes_disabled():
|
||||
self.queryset = self.queryset.filter(user=self.request.user)
|
||||
return Response(self.serializer_class(self.queryset.all(), many=True, context={'request': self.request}).data)
|
||||
|
||||
|
||||
class UserPreferenceViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
@@ -619,6 +636,29 @@ class SearchPreferenceViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
return self.queryset.filter(user=self.request.user)
|
||||
|
||||
|
||||
class AiProviderViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
queryset = AiProvider.objects
|
||||
serializer_class = AiProviderSerializer
|
||||
permission_classes = [CustomAiProviderPermission & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
# read only access to all space and global AiProviders
|
||||
with scopes_disabled():
|
||||
return self.queryset.filter(Q(space=self.request.space) | Q(space__isnull=True))
|
||||
|
||||
|
||||
class AiLogViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
queryset = AiLog.objects
|
||||
serializer_class = AiLogSerializer
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
http_method_names = ['get']
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
|
||||
|
||||
class StorageViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
# TODO handle delete protect error and adjust test
|
||||
queryset = Storage.objects
|
||||
@@ -917,6 +957,94 @@ class FoodViewSet(LoggingMixin, TreeMixin):
|
||||
content = {'error': True, 'msg': e.args[0]}
|
||||
return Response(content, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
@decorators.action(detail=False, methods=['PUT'], serializer_class=FoodBatchUpdateSerializer)
|
||||
def batch_update(self, request):
|
||||
serializer = self.serializer_class(data=request.data, partial=True)
|
||||
|
||||
if serializer.is_valid():
|
||||
foods = Food.objects.filter(id__in=serializer.validated_data['foods'], space=self.request.space)
|
||||
safe_food_ids = Food.objects.filter(id__in=serializer.validated_data['foods'], space=self.request.space).values_list('id', flat=True)
|
||||
|
||||
if 'category' in serializer.validated_data:
|
||||
foods.update(supermarket_category_id=serializer.validated_data['category'])
|
||||
|
||||
if 'ignore_shopping' in serializer.validated_data and serializer.validated_data['ignore_shopping'] is not None:
|
||||
foods.update(ignore_shopping=serializer.validated_data['ignore_shopping'])
|
||||
|
||||
if 'on_hand' in serializer.validated_data and serializer.validated_data['on_hand'] is not None:
|
||||
if serializer.validated_data['on_hand']:
|
||||
user_relation = []
|
||||
for f in safe_food_ids:
|
||||
user_relation.append(Food.onhand_users.through(food_id=f, user_id=request.user.id))
|
||||
Food.onhand_users.through.objects.bulk_create(user_relation, ignore_conflicts=True, unique_fields=('food_id', 'user_id',))
|
||||
else:
|
||||
Food.onhand_users.through.objects.filter(food_id__in=safe_food_ids, user_id=request.user.id).delete()
|
||||
|
||||
if 'substitute_children' in serializer.validated_data and serializer.validated_data['substitute_children'] is not None:
|
||||
foods.update(substitute_children=serializer.validated_data['substitute_children'])
|
||||
|
||||
if 'substitute_siblings' in serializer.validated_data and serializer.validated_data['substitute_siblings'] is not None:
|
||||
foods.update(substitute_siblings=serializer.validated_data['substitute_siblings'])
|
||||
|
||||
# ---------- substitutes -------------
|
||||
if 'substitute_add' in serializer.validated_data:
|
||||
add_to_relation(Food.substitute.through, 'from_food_id', safe_food_ids, 'to_food_id', serializer.validated_data['substitute_add'])
|
||||
|
||||
if 'substitute_remove' in serializer.validated_data:
|
||||
remove_from_relation(Food.substitute.through, 'from_food_id', safe_food_ids, 'to_food_id', serializer.validated_data['substitute_remove'])
|
||||
|
||||
if 'substitute_set' in serializer.validated_data and len(serializer.validated_data['substitute_set']) > 0:
|
||||
set_relation(Food.substitute.through, 'from_food_id', safe_food_ids, 'to_food_id', serializer.validated_data['substitute_set'])
|
||||
|
||||
if 'substitute_remove_all' in serializer.validated_data and serializer.validated_data['substitute_remove_all']:
|
||||
remove_all_from_relation(Food.substitute.through, 'from_food_id', safe_food_ids)
|
||||
|
||||
# ---------- inherit fields -------------
|
||||
if 'inherit_fields_add' in serializer.validated_data:
|
||||
add_to_relation(Food.inherit_fields.through, 'food_id', safe_food_ids, 'foodinheritfield_id', serializer.validated_data['inherit_fields_add'])
|
||||
|
||||
if 'inherit_fields_remove' in serializer.validated_data:
|
||||
remove_from_relation(Food.inherit_fields.through, 'food_id', safe_food_ids, 'foodinheritfield_id', serializer.validated_data['inherit_fields_remove'])
|
||||
|
||||
if 'inherit_fields_set' in serializer.validated_data and len(serializer.validated_data['inherit_fields_set']) > 0:
|
||||
set_relation(Food.inherit_fields.through, 'food_id', safe_food_ids, 'foodinheritfield_id', serializer.validated_data['inherit_fields_set'])
|
||||
|
||||
if 'inherit_fields_remove_all' in serializer.validated_data and serializer.validated_data['inherit_fields_remove_all']:
|
||||
remove_all_from_relation(Food.inherit_fields.through, 'food_id', safe_food_ids)
|
||||
|
||||
# ---------- child inherit fields -------------
|
||||
if 'child_inherit_fields_add' in serializer.validated_data:
|
||||
add_to_relation(Food.child_inherit_fields.through, 'food_id', safe_food_ids, 'foodinheritfield_id', serializer.validated_data['child_inherit_fields_add'])
|
||||
|
||||
if 'child_inherit_fields_remove' in serializer.validated_data:
|
||||
remove_from_relation(Food.child_inherit_fields.through, 'food_id', safe_food_ids, 'foodinheritfield_id', serializer.validated_data['child_inherit_fields_remove'])
|
||||
|
||||
if 'child_inherit_fields_set' in serializer.validated_data and len(serializer.validated_data['child_inherit_fields_set']) > 0:
|
||||
set_relation(Food.child_inherit_fields.through, 'food_id', safe_food_ids, 'foodinheritfield_id', serializer.validated_data['child_inherit_fields_set'])
|
||||
|
||||
if 'child_inherit_fields_remove_all' in serializer.validated_data and serializer.validated_data['child_inherit_fields_remove_all']:
|
||||
remove_all_from_relation(Food.child_inherit_fields.through, 'food_id', safe_food_ids)
|
||||
|
||||
# ------- parent --------
|
||||
if self.model.node_order_by:
|
||||
node_location = 'sorted'
|
||||
else:
|
||||
node_location = 'last'
|
||||
|
||||
if 'parent_remove' in serializer.validated_data and serializer.validated_data['parent_remove']:
|
||||
for f in foods:
|
||||
f.move(Food.get_first_root_node(), f'{node_location}-sibling')
|
||||
|
||||
if 'parent_set' in serializer.validated_data:
|
||||
parent_food = Food.objects.filter(space=request.space, id=serializer.validated_data['parent_set']).first()
|
||||
if parent_food:
|
||||
for f in foods:
|
||||
f.move(parent_food, f'{node_location}-child')
|
||||
|
||||
return Response({}, 200)
|
||||
|
||||
return Response(serializer.errors, 400)
|
||||
|
||||
|
||||
@extend_schema_view(list=extend_schema(parameters=[
|
||||
OpenApiParameter(name='order_field', description='Field to order recipe books on', type=str,
|
||||
@@ -1110,7 +1238,19 @@ class IngredientViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
return self.serializer_class
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.queryset.filter(step__recipe__space=self.request.space)
|
||||
queryset = self.queryset.prefetch_related('food',
|
||||
'food__properties',
|
||||
'food__properties__property_type',
|
||||
'food__inherit_fields',
|
||||
'food__supermarket_category',
|
||||
'food__onhand_users',
|
||||
'food__substitute',
|
||||
'food__child_inherit_fields',
|
||||
'unit',
|
||||
'unit__unit_conversion_base_relation',
|
||||
'unit__unit_conversion_base_relation__base_unit',
|
||||
'unit__unit_conversion_converted_relation',
|
||||
'unit__unit_conversion_converted_relation__converted_unit', ).filter(step__recipe__space=self.request.space)
|
||||
food = self.request.query_params.get('food', None)
|
||||
if food and re.match(r'^(\d)+$', food):
|
||||
queryset = queryset.filter(food_id=food)
|
||||
@@ -1451,9 +1591,26 @@ class RecipeViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
|
||||
return Response(serializer.errors, 400)
|
||||
|
||||
@extend_schema(responses=RecipeSerializer(many=False))
|
||||
@decorators.action(detail=True, pagination_class=None, methods=['PATCH'], serializer_class=RecipeSerializer)
|
||||
def delete_external(self, request, pk):
|
||||
obj = self.get_object()
|
||||
if obj.get_space() != request.space and has_group_permission(request.user, ['user']):
|
||||
raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403)
|
||||
|
||||
if obj.storage:
|
||||
get_recipe_provider(obj).delete_file(obj)
|
||||
obj.storage = None
|
||||
obj.file_path = ''
|
||||
obj.file_uid = ''
|
||||
obj.save()
|
||||
|
||||
return Response(self.serializer_class(obj, many=False, context={'request': request}).data)
|
||||
|
||||
|
||||
@extend_schema_view(list=extend_schema(
|
||||
parameters=[OpenApiParameter(name='food_id', description='ID of food to filter for', type=int), ]))
|
||||
parameters=[OpenApiParameter(name='food_id', description='ID of food to filter for', type=int),
|
||||
OpenApiParameter(name='query', description='query that looks into food, base unit or converted unit by name', type=str), ]))
|
||||
class UnitConversionViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
queryset = UnitConversion.objects
|
||||
serializer_class = UnitConversionSerializer
|
||||
@@ -1465,6 +1622,10 @@ class UnitConversionViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
if food_id is not None:
|
||||
self.queryset = self.queryset.filter(food_id=food_id)
|
||||
|
||||
query = self.request.query_params.get('query', None)
|
||||
if query is not None:
|
||||
self.queryset = self.queryset.filter(Q(food__name__icontains=query) | Q(base_unit__name__icontains=query) | Q(converted_unit__name__icontains=query))
|
||||
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
|
||||
|
||||
@@ -1490,6 +1651,27 @@ class PropertyTypeViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
self.queryset.filter(category__in=category)
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
|
||||
@extend_schema(responses=GenericModelSerializer(many=True))
|
||||
@decorators.action(detail=True, methods=['GET'], serializer_class=GenericModelSerializer, pagination_class=DefaultPagination)
|
||||
# TODO actually implement pagination
|
||||
def protecting(self, request, pk):
|
||||
obj = self.queryset.filter(pk=pk, space=request.space).first()
|
||||
if obj:
|
||||
collector = NestedObjects(using=DEFAULT_DB_ALIAS)
|
||||
collector.collect([obj])
|
||||
|
||||
protected_objects = []
|
||||
for o in collector.protected:
|
||||
protected_objects.append({
|
||||
'id': o.pk,
|
||||
'model': o.__class__.__name__,
|
||||
'name': str(o),
|
||||
})
|
||||
|
||||
return Response(self.serializer_class(protected_objects, many=True, context={'request': request}).data)
|
||||
else:
|
||||
return Response({}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class PropertyViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
queryset = Property.objects
|
||||
@@ -1760,8 +1942,8 @@ class InviteLinkViewSet(LoggingMixin, StandardFilterModelViewSet):
|
||||
if internal_note is not None:
|
||||
self.queryset = self.queryset.filter(internal_note=internal_note)
|
||||
|
||||
unused = self.request.query_params.get('unused', False)
|
||||
if unused:
|
||||
used = self.request.query_params.get('used', False)
|
||||
if not used:
|
||||
self.queryset = self.queryset.filter(used_by=None)
|
||||
|
||||
if is_space_owner(self.request.user, self.request.space):
|
||||
@@ -1981,6 +2163,24 @@ class AiImportView(APIView):
|
||||
if serializer.is_valid():
|
||||
# TODO max file size check
|
||||
|
||||
if 'ai_provider_id' not in serializer.validated_data:
|
||||
response = {
|
||||
'error': True,
|
||||
'msg': _('You must select an AI provider to perform your request.'),
|
||||
}
|
||||
return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not can_perform_ai_request(request.space):
|
||||
response = {
|
||||
'error': True,
|
||||
'msg': _("You don't have any credits remaining to use AI or AI features are not enabled for your space."),
|
||||
}
|
||||
return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
ai_provider = AiProvider.objects.filter(pk=serializer.validated_data['ai_provider_id']).filter(Q(space=request.space) | Q(space__isnull=True)).first()
|
||||
|
||||
litellm.callbacks = [AiCallbackHandler(request.space, request.user, ai_provider)]
|
||||
|
||||
messages = []
|
||||
uploaded_file = serializer.validated_data['file']
|
||||
|
||||
@@ -2049,7 +2249,15 @@ class AiImportView(APIView):
|
||||
return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
ai_response = completion(api_key=AI_API_KEY, model=AI_MODEL_NAME, response_format={"type": "json_object"}, messages=messages, )
|
||||
ai_request = {
|
||||
'api_key': ai_provider.api_key,
|
||||
'model': ai_provider.model_name,
|
||||
'response_format': {"type": "json_object"},
|
||||
'messages': messages,
|
||||
}
|
||||
if ai_provider.url:
|
||||
ai_request['api_base'] = ai_provider.url
|
||||
ai_response = completion(**ai_request)
|
||||
except BadRequestError as err:
|
||||
response = {
|
||||
'error': True,
|
||||
@@ -2108,7 +2316,13 @@ class AppImportView(APIView):
|
||||
files = []
|
||||
for f in request.FILES.getlist('files'):
|
||||
files.append({'file': io.BytesIO(f.read()), 'name': f.name})
|
||||
t = threading.Thread(target=integration.do_import, args=[files, il, form.cleaned_data['duplicates']])
|
||||
t = threading.Thread(target=integration.do_import,
|
||||
args=[files, il, form.cleaned_data['duplicates']],
|
||||
kwargs={'meal_plans': form.cleaned_data['meal_plans'],
|
||||
'shopping_lists': form.cleaned_data['shopping_lists'],
|
||||
'nutrition_per_serving': form.cleaned_data['nutrition_per_serving']
|
||||
}
|
||||
)
|
||||
t.setDaemon(True)
|
||||
t.start()
|
||||
|
||||
@@ -2354,7 +2568,6 @@ class ServerSettingsViewSet(viewsets.GenericViewSet):
|
||||
# Attention: No login required, do not return sensitive data
|
||||
s['shopping_min_autosync_interval'] = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL
|
||||
s['enable_pdf_export'] = settings.ENABLE_PDF_EXPORT
|
||||
s['enable_ai_import'] = settings.AI_API_KEY != ''
|
||||
s['disable_external_connectors'] = settings.DISABLE_EXTERNAL_CONNECTORS
|
||||
s['terms_url'] = settings.TERMS_URL
|
||||
s['privacy_url'] = settings.PRIVACY_URL
|
||||
@@ -2507,7 +2720,7 @@ def meal_plans_to_ical(queryset, filename):
|
||||
request=inline_serializer(name="IngredientStringSerializer", fields={'text': CharField()}),
|
||||
responses=inline_serializer(name="ParsedIngredientSerializer",
|
||||
fields={'amount': IntegerField(), 'unit': CharField(), 'food': CharField(),
|
||||
'note': CharField()})
|
||||
'note': CharField(), 'original_text': CharField()})
|
||||
)
|
||||
@api_view(['POST'])
|
||||
@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope])
|
||||
@@ -2517,13 +2730,19 @@ def ingredient_from_string(request):
|
||||
ingredient_parser = IngredientParser(request, False)
|
||||
amount, unit, food, note = ingredient_parser.parse(text)
|
||||
|
||||
ingredient = {'amount': amount, 'unit': None, 'food': None, 'note': note}
|
||||
ingredient = {'amount': amount, 'unit': None, 'food': None, 'note': note, 'original_text': text}
|
||||
if food:
|
||||
food, created = Food.objects.get_or_create(space=request.space, name=food)
|
||||
ingredient['food'] = {'name': food.name, 'id': food.id}
|
||||
if food_obj := Food.objects.filter(space=request.space).filter(Q(name=food) | Q(plural_name=food)).first():
|
||||
ingredient['food'] = {'name': food_obj.name, 'id': food_obj.id}
|
||||
else:
|
||||
food_obj = Food.objects.create(space=request.space, name=food)
|
||||
ingredient['food'] = {'name': food_obj.name, 'id': food_obj.id}
|
||||
|
||||
if unit:
|
||||
unit, created = Unit.objects.get_or_create(space=request.space, name=unit)
|
||||
ingredient['unit'] = {'name': unit.name, 'id': unit.id}
|
||||
if unit_obj := Unit.objects.filter(space=request.space).filter(Q(name=unit) | Q(plural_name=unit)).first():
|
||||
ingredient['unit'] = {'name': unit_obj.name, 'id': unit_obj.id}
|
||||
else:
|
||||
unit_obj = Unit.objects.create(space=request.space, name=unit)
|
||||
ingredient['unit'] = {'name': unit_obj.name, 'id': unit_obj.id}
|
||||
|
||||
return JsonResponse(ingredient, status=200)
|
||||
|
||||
@@ -17,6 +17,7 @@ from cookbook.integration.copymethat import CopyMeThat
|
||||
from cookbook.integration.default import Default
|
||||
from cookbook.integration.domestica import Domestica
|
||||
from cookbook.integration.mealie import Mealie
|
||||
from cookbook.integration.mealie1 import Mealie1
|
||||
from cookbook.integration.mealmaster import MealMaster
|
||||
from cookbook.integration.melarecipes import MelaRecipes
|
||||
from cookbook.integration.nextcloud_cookbook import NextcloudCookbook
|
||||
@@ -45,6 +46,8 @@ def get_integration(request, export_type):
|
||||
return NextcloudCookbook(request, export_type)
|
||||
if export_type == ImportExportBase.MEALIE:
|
||||
return Mealie(request, export_type)
|
||||
if export_type == ImportExportBase.MEALIE1:
|
||||
return Mealie1(request, export_type)
|
||||
if export_type == ImportExportBase.CHOWDOWN:
|
||||
return Chowdown(request, export_type)
|
||||
if export_type == ImportExportBase.SAFFRON:
|
||||
|
||||
@@ -54,7 +54,7 @@ def hook(request, token):
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
|
||||
ShoppingListEntry.objects.create(food=f, unit=u, amount=amount, created_by=request.user, space=request.space)
|
||||
ShoppingListEntry.objects.create(food=f, unit=u, amount=max(1, amount), created_by=request.user, space=request.space)
|
||||
|
||||
return JsonResponse({'data': data['message']['text']})
|
||||
except Exception:
|
||||
|
||||
@@ -21,7 +21,7 @@ from django.http import HttpResponseRedirect, JsonResponse, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.datetime_safe import date
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
from drf_spectacular.views import SpectacularRedocView, SpectacularSwaggerView
|
||||
@@ -42,6 +42,9 @@ def index(request, path=None, resource=None):
|
||||
if User.objects.count() < 1 and 'django.contrib.auth.backends.RemoteUserBackend' not in settings.AUTHENTICATION_BACKENDS:
|
||||
return HttpResponseRedirect(reverse_lazy('view_setup'))
|
||||
|
||||
if 'signup_token' in request.session:
|
||||
return HttpResponseRedirect(reverse('view_invite', args=[request.session.pop('signup_token', '')]))
|
||||
|
||||
if request.user.is_authenticated or re.search(r'/recipe/\d+/', request.path[:512]) and request.GET.get('share'):
|
||||
return render(request, 'frontend/tandoor.html', {})
|
||||
else:
|
||||
@@ -97,7 +100,8 @@ def space_overview(request):
|
||||
max_recipes=settings.SPACE_DEFAULT_MAX_RECIPES,
|
||||
max_users=settings.SPACE_DEFAULT_MAX_USERS,
|
||||
allow_sharing=settings.SPACE_DEFAULT_ALLOW_SHARING,
|
||||
)
|
||||
ai_enabled=settings.SPACE_AI_ENABLED,
|
||||
ai_credits_monthly=settings.SPACE_AI_CREDITS_MONTHLY, )
|
||||
|
||||
user_space = UserSpace.objects.create(space=created_space, user=request.user, active=False)
|
||||
user_space.groups.add(Group.objects.filter(name='admin').get())
|
||||
@@ -222,7 +226,7 @@ def system(request):
|
||||
total_stats = ['All', int(r.get('api:request-count'))]
|
||||
|
||||
for i in range(0, 6):
|
||||
d = (date.today() - timedelta(days=i)).isoformat()
|
||||
d = (timezone.now() - timedelta(days=i)).isoformat()
|
||||
api_stats[0].append(d)
|
||||
api_space_stats[0].append(d)
|
||||
total_stats.append(int(r.get(f'api:request-count:{d}')) if r.get(f'api:request-count:{d}') else 0)
|
||||
@@ -233,7 +237,7 @@ def system(request):
|
||||
endpoint = x[0].decode('utf-8')
|
||||
endpoint_stats = [endpoint, x[1]]
|
||||
for i in range(0, 6):
|
||||
d = (date.today() - timedelta(days=i)).isoformat()
|
||||
d = (timezone.now() - timedelta(days=i)).isoformat()
|
||||
endpoint_stats.append(r.zscore(f'api:endpoint-request-count:{d}', endpoint))
|
||||
api_stats.append(endpoint_stats)
|
||||
|
||||
@@ -242,7 +246,7 @@ def system(request):
|
||||
if space := Space.objects.filter(pk=s).first():
|
||||
space_stats = [space.name, x[1]]
|
||||
for i in range(0, 6):
|
||||
d = (date.today() - timedelta(days=i)).isoformat()
|
||||
d = (timezone.now() - timedelta(days=i)).isoformat()
|
||||
space_stats.append(r.zscore(f'api:space-request-count:{d}', s))
|
||||
api_space_stats.append(space_stats)
|
||||
|
||||
@@ -321,7 +325,7 @@ def invite_link(request, token):
|
||||
try:
|
||||
token = UUID(token, version=4)
|
||||
except ValueError:
|
||||
messages.add_message(request, messages.ERROR, _('Malformed Invite Link supplied!'))
|
||||
print('Malformed Invite Link supplied!')
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
if link := InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, uuid=token).first():
|
||||
@@ -330,22 +334,17 @@ def invite_link(request, token):
|
||||
link.used_by = request.user
|
||||
link.save()
|
||||
|
||||
user_space = UserSpace.objects.create(user=request.user, space=link.space, internal_note=link.internal_note, invite_link=link, active=False)
|
||||
|
||||
if request.user.userspace_set.count() == 1:
|
||||
user_space.active = True
|
||||
user_space.save()
|
||||
UserSpace.objects.filter(user=request.user).update(active=False)
|
||||
user_space = UserSpace.objects.create(user=request.user, space=link.space, internal_note=link.internal_note, invite_link=link, active=True)
|
||||
|
||||
user_space.groups.add(link.group)
|
||||
|
||||
messages.add_message(request, messages.SUCCESS, _('Successfully joined space.'))
|
||||
return HttpResponseRedirect(reverse('view_space_overview'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
else:
|
||||
request.session['signup_token'] = str(token)
|
||||
return HttpResponseRedirect(reverse('account_signup'))
|
||||
|
||||
messages.add_message(request, messages.ERROR, _('Invite Link not valid or already used!'))
|
||||
return HttpResponseRedirect(reverse('view_space_overview'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
|
||||
def report_share_abuse(request, token):
|
||||
|
||||
@@ -33,17 +33,26 @@ VS Marketplace Link: https://marketplace.visualstudio.com/items?itemName=esbenp.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! note
|
||||
In order to debug vue yarn and vite servers must be started before starting the django server.
|
||||
In order to hot reload vue, the `yarn dev` server must be started before starting the django server.
|
||||
|
||||
There are a number of built in tasks that are available. Here are a few of the key ones:
|
||||
|
||||
- `Setup Dev Server` - Runs all the prerequisite steps so that the dev server can be run inside VSCode.
|
||||
- `Setup Tests` - Runs all prerequisites so tests can be run inside VSCode.
|
||||
|
||||
Once these are run, you should be able to run/debug a django server in VSCode as well as run/debug tests directly through VSCode.
|
||||
There are also a few other tasks specified in case you have specific development needs:
|
||||
Once these are run, there are 2 options. If you want to run a vue3 server in a hot reload mode for quick development of the frontend, you should run a development vue server:
|
||||
|
||||
- `Yarn Dev` - Runs development Vue.js vite server not connected to VSCode. Useful if you want to make Vue changes and see them in realtime.
|
||||
|
||||
If not, you need to build and copy the frontend to the django server. If you make changes to the frontend, you need to re-run this and restart the django server:
|
||||
|
||||
- `Collect Static Files` - Builds and collects the vue3 frontend so that it can be served via the django server.
|
||||
|
||||
Once either of those steps are done, you can start the django server:
|
||||
|
||||
- `Run Dev Server` - Runs a django development server not connected to VSCode.
|
||||
|
||||
There are also a few other tasks specified in case you have specific development needs:
|
||||
|
||||
- `Run all pytests` - Runs all the pytests outside of VSCode.
|
||||
- `Yarn Serve` - Runs development Vue.js server not connected to VSCode. Useful if you want to make Vue changes and see them in realtime.
|
||||
- `Serve Documentation` - Runs a documentation server. Useful if you want to see how changes to documentation show up.
|
||||
|
||||
18
docs/features/ai.md
Normal file
18
docs/features/ai.md
Normal file
@@ -0,0 +1,18 @@
|
||||
Tandoor has several AI based features. To allow maximum flexibility, you can configure different AI providers and select them based on the task you want to perform.
|
||||
To prevent accidental cost escalation Tandoor has a robust system of tracking and limiting AI costs.
|
||||
|
||||
## Default Configuration
|
||||
By default the AI features are enabled for every space. Each space has a spending limit of roughly 1 USD per month.
|
||||
This can be changed using the [configuration variables](https://docs.tandoor.dev/system/configuration/#ai-integration)
|
||||
|
||||
You can change these settings any time using the django admin. If you do not care about AI cost you can enter a very high limit or disable cost tracking for your providers.
|
||||
The limit resets on the first of every month.
|
||||
|
||||
## Configure AI Providers
|
||||
When AI support is enabled for a space every user in a space can configure AI providers.
|
||||
The models shown in the editor have been tested and work with Tandoor. Most other models that can parse images/files and return text should also work.
|
||||
|
||||
Superusers also have the ability to configure global AI providers that every space can use.
|
||||
|
||||
## AI Log
|
||||
The AI Log allows you to track the usage of AI calls. Here you can also see the usage.
|
||||
@@ -97,10 +97,17 @@ Follow these steps to import your recipes
|
||||
|
||||
Mealie provides structured data similar to nextcloud.
|
||||
|
||||
!!! WARNING "Versions"
|
||||
There are two different versions of the Mealie importer. One for all backups created prior to Version 1.0 and one for all after.
|
||||
|
||||
!!! INFO "Versions"
|
||||
The Mealie UI does not indicate weather or not nutrition information is stored per serving or per recipe. This choice is left to the user. During the import you will have to choose
|
||||
how Tandoor should treat your nutrition data.
|
||||
|
||||
To migrate your recipes
|
||||
|
||||
1. Go to your Mealie settings and create a new Backup.
|
||||
2. Download the backup by clicking on it and pressing download (this wasn't working for me, so I had to manually pull it from the server).
|
||||
1. Go to your Mealie admin settings and create a new backup.
|
||||
2. Download the backup.
|
||||
3. Upload the entire `.zip` file to the importer page and import everything.
|
||||
|
||||
## Chowdown
|
||||
|
||||
@@ -196,6 +196,7 @@ server {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_pass http://unix:/var/www/recipes/recipes.sock;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -472,15 +472,20 @@ S3_CUSTOM_DOMAIN= # when using a CDN/proxy to S3 (see https://github.com/Tandoor
|
||||
|
||||
#### AI Integration
|
||||
|
||||
To use AI to perform different tasks you need to configure an API key and the AI provider. [LiteLLM](https://www.litellm.ai/) is used
|
||||
to make a standardized request to different AI providers of your liking.
|
||||
|
||||
Configuring this via environment parameters is a temporary solution. In the future I plan on adding support for multiple AI providers per Tandoor instance
|
||||
with the option to select them for various tasks. For now only gemini 2.0 flash has been tested but feel free to try out other models.
|
||||
Most AI features are configured trough the AI Provider settings in the Tandoor web interface. Some defaults can be set for new spaces on your instance.
|
||||
|
||||
Enables AI features for spaces by default
|
||||
```
|
||||
SPACE_AI_ENABLED=1
|
||||
```
|
||||
|
||||
Sets the monthly default credit limit for AI usage
|
||||
```
|
||||
SPACE_AI_CREDITS_MONTHLY=100
|
||||
```
|
||||
|
||||
Ratelimit for AI API
|
||||
```
|
||||
AI_API_KEY=
|
||||
AI_MODEL_NAME=gemini/gemini-2.0-flash
|
||||
AI_RATELIMIT=60/hour
|
||||
```
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import traceback
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
#TODO clean existing links for when plugins are uninstalled or not necessary because it will just be empty links?
|
||||
|
||||
PLUGINS_DIRECTORY = os.path.join(BASE_DIR, 'recipes', 'plugins')
|
||||
if os.path.isdir(PLUGINS_DIRECTORY):
|
||||
for d in os.listdir(PLUGINS_DIRECTORY):
|
||||
|
||||
@@ -59,6 +59,8 @@ SPACE_DEFAULT_MAX_RECIPES = int(os.getenv('SPACE_DEFAULT_MAX_RECIPES', 0))
|
||||
SPACE_DEFAULT_MAX_USERS = int(os.getenv('SPACE_DEFAULT_MAX_USERS', 0))
|
||||
SPACE_DEFAULT_MAX_FILES = int(os.getenv('SPACE_DEFAULT_MAX_FILES', 0))
|
||||
SPACE_DEFAULT_ALLOW_SHARING = extract_bool('SPACE_DEFAULT_ALLOW_SHARING', True)
|
||||
SPACE_AI_ENABLED = extract_bool('SPACE_AI_ENABLED', True)
|
||||
SPACE_AI_CREDITS_MONTHLY = int(os.getenv('SPACE_AI_CREDITS_MONTHLY', 10000))
|
||||
|
||||
INTERNAL_IPS = extract_comma_list('INTERNAL_IPS', '127.0.0.1')
|
||||
|
||||
@@ -137,8 +139,6 @@ HCAPTCHA_SECRET = os.getenv('HCAPTCHA_SECRET', '')
|
||||
|
||||
FDC_API_KEY = os.getenv('FDC_API_KEY', 'DEMO_KEY')
|
||||
|
||||
AI_API_KEY = os.getenv('AI_API_KEY', '')
|
||||
AI_MODEL_NAME = os.getenv('AI_MODEL_NAME', 'gemini/gemini-2.0-flash')
|
||||
AI_RATELIMIT = os.getenv('AI_RATELIMIT', '60/hour')
|
||||
|
||||
SHARING_ABUSE = extract_bool('SHARING_ABUSE', False)
|
||||
@@ -565,8 +565,6 @@ else:
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
LANGUAGES = [
|
||||
@@ -594,8 +592,18 @@ LANGUAGES = [
|
||||
|
||||
AWS_ENABLED = True if os.getenv('S3_ACCESS_KEY', False) else False
|
||||
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
# Serve static files with gzip
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
if os.getenv('S3_ACCESS_KEY', ''):
|
||||
DEFAULT_FILE_STORAGE = 'cookbook.helper.CustomStorageClass.CachedS3Boto3Storage'
|
||||
STORAGES['default']['BACKEND'] = 'cookbook.helper.CustomStorageClass.CachedS3Boto3Storage'
|
||||
|
||||
AWS_ACCESS_KEY_ID = os.getenv('S3_ACCESS_KEY', '')
|
||||
AWS_SECRET_ACCESS_KEY = os.getenv('S3_SECRET_ACCESS_KEY', '')
|
||||
@@ -610,14 +618,9 @@ if os.getenv('S3_ACCESS_KEY', ''):
|
||||
if os.getenv('S3_CUSTOM_DOMAIN', ''):
|
||||
AWS_S3_CUSTOM_DOMAIN = os.getenv('S3_CUSTOM_DOMAIN', '')
|
||||
|
||||
MEDIA_URL = os.getenv('MEDIA_URL', '/media/')
|
||||
MEDIA_ROOT = os.getenv('MEDIA_ROOT', os.path.join(BASE_DIR, "mediafiles"))
|
||||
else:
|
||||
MEDIA_URL = os.getenv('MEDIA_URL', '/media/')
|
||||
MEDIA_ROOT = os.getenv('MEDIA_ROOT', os.path.join(BASE_DIR, "mediafiles"))
|
||||
|
||||
# Serve static files with gzip
|
||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||
MEDIA_URL = os.getenv('MEDIA_URL', '/media/')
|
||||
MEDIA_ROOT = os.getenv('MEDIA_ROOT', os.path.join(BASE_DIR, "mediafiles"))
|
||||
|
||||
# settings for cross site origin (CORS)
|
||||
# all origins allowed to support bookmarklet
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
Django==4.2.22
|
||||
Django==5.2.6
|
||||
cryptography===45.0.5
|
||||
django-annoying==0.10.6
|
||||
django-cleanup==9.0.0
|
||||
django-crispy-forms==2.4
|
||||
crispy-bootstrap4==2025.6
|
||||
djangorestframework==3.15.2
|
||||
djangorestframework==3.16.1
|
||||
drf-spectacular==0.27.1
|
||||
drf-spectacular-sidecar==2025.7.1
|
||||
drf-spectacular-sidecar==2025.8.1
|
||||
drf-writable-nested==0.7.2
|
||||
django-oauth-toolkit==2.4.0
|
||||
django-debug-toolbar==4.3.0
|
||||
@@ -16,7 +16,7 @@ lxml==5.3.1
|
||||
Markdown==3.7
|
||||
Pillow==11.3.0
|
||||
psycopg2-binary==2.9.10
|
||||
python-dotenv==1.0.0
|
||||
python-dotenv==1.1.1
|
||||
requests==2.32.4
|
||||
six==1.17.0
|
||||
webdavclient3==3.14.6
|
||||
@@ -33,9 +33,9 @@ recipe-scrapers==15.8.0
|
||||
django-scopes==2.0.0
|
||||
django-treebeard==4.7.1
|
||||
django-cors-headers==4.6.0
|
||||
django-storages==1.14.2
|
||||
django-storages==1.14.6
|
||||
boto3==1.28.75
|
||||
django-prometheus==2.3.1
|
||||
django-prometheus==2.4.1
|
||||
django-hCaptcha==0.2.0
|
||||
python-ldap==3.4.4
|
||||
django-auth-ldap==4.6.0
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@vueform/multiselect": "^2.6.11",
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"@vueuse/router": "^13.6.0",
|
||||
@@ -18,12 +19,11 @@
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-draggable-plus": "^0.6.0",
|
||||
"vue-i18n": "^11.1.10",
|
||||
"vue-i18n": "^11.1.11",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-simple-calendar": "7.1.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"vuetify": "^3.9.3"
|
||||
"vuetify": "^3.9.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
@@ -32,20 +32,21 @@
|
||||
"@types/node": "^24.0.8",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"esbuild-register": "^3.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.2",
|
||||
"workbox-core": "^7.3.0",
|
||||
"workbox-build": "^7.3.0",
|
||||
"workbox-window": "^7.3.0",
|
||||
"vite": "7.1.5",
|
||||
"vite-plugin-pwa": "^1.0.3",
|
||||
"vite-plugin-vuetify": "^2.1.1",
|
||||
"vue-tsc": "^3.0.6",
|
||||
"workbox-background-sync": "^7.3.0",
|
||||
"workbox-build": "^7.3.0",
|
||||
"workbox-core": "^7.3.0",
|
||||
"workbox-expiration": "^7.3.0",
|
||||
"workbox-navigation-preload": "^7.3.0",
|
||||
"workbox-precaching": "^7.3.0",
|
||||
"workbox-routing": "^7.3.0",
|
||||
"workbox-strategies": "^7.3.0",
|
||||
"vite-plugin-vuetify": "^2.1.1",
|
||||
"vue-tsc": "^2.2.8"
|
||||
"workbox-window": "^7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,13 +156,20 @@ const router = useRouter()
|
||||
const isPrintMode = useMediaQuery('print')
|
||||
|
||||
onMounted(() => {
|
||||
useUserPreferenceStore()
|
||||
useUserPreferenceStore().init().then(() => {
|
||||
if (useUserPreferenceStore().activeSpace.spaceSetupCompleted != undefined && !useUserPreferenceStore().activeSpace.spaceSetupCompleted) {
|
||||
router.push({name: 'WelcomePage'})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* global title update handler, might be overridden by page specific handlers
|
||||
*/
|
||||
router.afterEach((to, from) => {
|
||||
if(to.name == 'StartPage' && useUserPreferenceStore().initCompleted && !useUserPreferenceStore().activeSpace.spaceSetupCompleted != undefined &&!useUserPreferenceStore().activeSpace.spaceSetupCompleted && useUserPreferenceStore().activeSpace.createdBy.id! == useUserPreferenceStore().userSettings.user.id!){
|
||||
router.push({name: 'WelcomePage'})
|
||||
}
|
||||
nextTick(() => {
|
||||
if (to.meta.title) {
|
||||
title.value = t(to.meta.title)
|
||||
|
||||
@@ -18,6 +18,7 @@ let routes = [
|
||||
{path: '/', component: () => import("@/pages/StartPage.vue"), name: 'StartPage' },
|
||||
{path: '/search', redirect: {name: 'StartPage'}},
|
||||
{path: '/test', component: () => import("@/pages/TestPage.vue"), name: 'view_test'},
|
||||
{path: '/welcome', component: () => import("@/pages/WelcomePage.vue"), name: 'WelcomePage', meta: {title: 'Welcome'}},
|
||||
{path: '/help', component: () => import("@/pages/HelpPage.vue"), name: 'HelpPage', meta: {title: 'Help'}},
|
||||
{
|
||||
path: '/settings', component: () => import("@/pages/SettingsPage.vue"), name: 'SettingsPage', redirect: '/settings/account',
|
||||
@@ -28,8 +29,6 @@ let routes = [
|
||||
{path: 'meal-plan', component: () => import("@/components/settings/MealPlanSettings.vue"), name: 'MealPlanSettings', meta: {title: 'Settings'}},
|
||||
{path: 'search', component: () => import("@/components/settings/SearchSettings.vue"), name: 'SearchSettings', meta: {title: 'Settings'}},
|
||||
{path: 'space', component: () => import("@/components/settings/SpaceSettings.vue"), name: 'SpaceSettings', meta: {title: 'Settings'}},
|
||||
{path: 'space-members', component: () => import("@/components/settings/SpaceMemberSettings.vue"), name: 'SpaceMemberSettings', meta: {title: 'Settings'}},
|
||||
{path: 'user-space', component: () => import("@/components/settings/UserSpaceSettings.vue"), name: 'UserSpaceSettings', meta: {title: 'Settings'}},
|
||||
{path: 'open-data-import', component: () => import("@/components/settings/OpenDataImportSettings.vue"), name: 'OpenDataImportSettings', meta: {title: 'Settings'}},
|
||||
{path: 'export', component: () => import("@/components/settings/ExportDataSettings.vue"), name: 'ExportDataSettings', meta: {title: 'Settings'}},
|
||||
{path: 'api', component: () => import("@/components/settings/ApiSettings.vue"), name: 'ApiSettings', meta: {title: 'Settings'}},
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 13 KiB |
114
vue3/src/components/dialogs/AutoPlanDialog.vue
Normal file
114
vue3/src/components/dialogs/AutoPlanDialog.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<v-dialog max-width="600px" :activator="props.activator" v-model="dialog">
|
||||
<v-card :loading="loading">
|
||||
<v-closable-card-title v-model="dialog" :title="$t('Auto_Planner')" icon="fa-solid fa-calendar-plus"></v-closable-card-title>
|
||||
|
||||
<v-card-text>
|
||||
|
||||
<v-form>
|
||||
<model-select model="MealType" v-model="autoMealPlan.mealTypeId" :object="false"></model-select>
|
||||
<model-select model="Keyword" v-model="autoMealPlan.keywordIds" mode="tags" :object="false"></model-select>
|
||||
|
||||
<v-number-input :label="$t('Servings')" v-model="autoMealPlan.servings"></v-number-input>
|
||||
|
||||
<v-date-input :label="$t('Date')"
|
||||
multiple="range"
|
||||
v-model="dateRangeValue"
|
||||
:first-day-of-week="useUserPreferenceStore().deviceSettings.mealplan_startingDayOfWeek"
|
||||
:show-week="useUserPreferenceStore().deviceSettings.mealplan_displayWeekNumbers"
|
||||
prepend-icon=""
|
||||
prepend-inner-icon="$calendar"
|
||||
></v-date-input>
|
||||
|
||||
<model-select model="User" v-model="autoMealPlan.shared" mode="tags"></model-select>
|
||||
<v-checkbox v-model="autoMealPlan.addshopping" :label="$t('AddToShopping')" hide-details></v-checkbox>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn @click="dialog = false">{{ $t('Cancel') }}</v-btn>
|
||||
<v-btn color="create" prepend-icon="fa-solid fa-person-running" @click="doAutoPlan()" :loading="loading">{{ $t('Create') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {useI18n} from "vue-i18n";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
import {ApiApi, AutoMealPlan} from "@/openapi";
|
||||
import {onMounted, ref} from "vue";
|
||||
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
|
||||
import {VDateInput} from 'vuetify/labs/VDateInput'
|
||||
import {DateTime} from "luxon";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import {useMealPlanStore} from "@/stores/MealPlanStore.ts";
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const props = defineProps({
|
||||
activator: {type: String, default: 'parent'},
|
||||
})
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const dialog = defineModel<boolean>({default: false})
|
||||
const loading = ref(false)
|
||||
|
||||
const dateRangeValue = ref([] as Date[])
|
||||
const autoMealPlan = ref({} as AutoMealPlan)
|
||||
|
||||
onMounted(() => {
|
||||
initializeRequest()
|
||||
})
|
||||
|
||||
/**
|
||||
* load default values for auto plan creation
|
||||
*/
|
||||
function initializeRequest() {
|
||||
autoMealPlan.value = {
|
||||
servings: 1,
|
||||
startDate: DateTime.now().toJSDate(),
|
||||
endDate: DateTime.now().plus({day: 7}).toJSDate(),
|
||||
shared: useUserPreferenceStore().userSettings.planShare,
|
||||
addshopping: useUserPreferenceStore().userSettings.mealplanAutoaddShopping,
|
||||
} as AutoMealPlan
|
||||
|
||||
dateRangeValue.value = []
|
||||
let currentDate = DateTime.fromJSDate(autoMealPlan.value.startDate).plus({day: 1}).toJSDate()
|
||||
while (currentDate <= autoMealPlan.value.endDate) {
|
||||
dateRangeValue.value.push(currentDate)
|
||||
currentDate = DateTime.fromJSDate(currentDate).plus({day: 1}).toJSDate()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* perform auto plan creation
|
||||
*/
|
||||
function doAutoPlan() {
|
||||
let api = new ApiApi()
|
||||
loading.value = true
|
||||
|
||||
autoMealPlan.value.startDate = dateRangeValue.value[0]
|
||||
autoMealPlan.value.endDate = dateRangeValue.value[dateRangeValue.value.length - 1]
|
||||
console.log('requesting auto plan from ', autoMealPlan.value.startDate, ' to ', autoMealPlan.value.endDate)
|
||||
|
||||
api.apiAutoPlanCreate({autoMealPlan: autoMealPlan.value}).then(r => {
|
||||
dialog.value = false
|
||||
useMealPlanStore().refreshLastUpdatedPeriod()
|
||||
initializeRequest()
|
||||
useMessageStore().addPreparedMessage(PreparedMessage.CREATE_SUCCESS)
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.CREATE_ERROR, err)
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
186
vue3/src/components/dialogs/BatchEditFoodDialog.vue
Normal file
186
vue3/src/components/dialogs/BatchEditFoodDialog.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<v-dialog max-width="1200px" :activator="props.activator" v-model="dialog">
|
||||
<v-card :loading="loading">
|
||||
<v-closable-card-title
|
||||
:title="$t('BatchEdit')"
|
||||
:sub-title="$t('BatchEditUpdatingItemsCount', {type: $t('Foods'), count: updateItems.length})"
|
||||
:icon="TFood.icon"
|
||||
v-model="dialog"
|
||||
></v-closable-card-title>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text>
|
||||
|
||||
<v-form>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card :title="$t('Miscellaneous')" prepend-icon="fa-solid fa-list" variant="flat">
|
||||
<v-card-text>
|
||||
<model-select model="SupermarketCategory" v-model="batchUpdateRequest.foodBatchUpdate.category" :object="false" allow-create mode="single">
|
||||
</model-select>
|
||||
|
||||
<v-select :items="boolUpdateOptions" :label="$t('Ignore_Shopping')" clearable v-model="batchUpdateRequest.foodBatchUpdate.ignoreShopping"></v-select>
|
||||
<v-select :items="boolUpdateOptions" :label="$t('OnHand')" clearable v-model="batchUpdateRequest.foodBatchUpdate.onHand"></v-select>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-label :text="$t('Substitutes')"></v-label>
|
||||
<model-select model="Food" v-model="batchUpdateRequest.foodBatchUpdate.substituteAdd" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-add"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="Food" v-model="batchUpdateRequest.foodBatchUpdate.substituteRemove" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-minus"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="Food" v-model="batchUpdateRequest.foodBatchUpdate.substituteSet" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-equals"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<v-checkbox :label="$t('RemoveAllType', {type: $t('Substitutes')})" hide-details
|
||||
v-model="batchUpdateRequest.foodBatchUpdate.substituteRemoveAll"></v-checkbox>
|
||||
|
||||
<v-select :items="boolUpdateOptions" :label="$t('substitute_siblings')" clearable v-model="batchUpdateRequest.foodBatchUpdate.substituteChildren"></v-select>
|
||||
<v-select :items="boolUpdateOptions" :label="$t('substitute_children')" clearable v-model="batchUpdateRequest.foodBatchUpdate.substituteSiblings"></v-select>
|
||||
|
||||
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card :title="$t('Hierarchy')" prepend-icon="fa-solid fa-folder-tree" variant="flat">
|
||||
<v-card-text>
|
||||
<model-select model="Food" :label="$t('Parent')" :object="false" allow-create clearable v-model="batchUpdateRequest.foodBatchUpdate.parentSet">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-equals"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
|
||||
<v-select :items="boolUpdateOptions" :label="$t('RemoveParent')" clearable v-model="batchUpdateRequest.foodBatchUpdate.parentRemove"></v-select>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-label :text="$t('InheritFields')"></v-label>
|
||||
<model-select model="FoodInheritField" v-model="batchUpdateRequest.foodBatchUpdate.inheritFieldsAdd" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-add"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="FoodInheritField" v-model="batchUpdateRequest.foodBatchUpdate.inheritFieldsRemove" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-minus"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="FoodInheritField" v-model="batchUpdateRequest.foodBatchUpdate.inheritFieldsSet" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-equals"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<v-checkbox :label="$t('RemoveAllType', {type: $t('InheritFields')})" hide-details
|
||||
v-model="batchUpdateRequest.foodBatchUpdate.inheritFieldsRemoveAll"></v-checkbox>
|
||||
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-label :text="$t('ChildInheritFields')"></v-label>
|
||||
|
||||
<model-select model="FoodInheritField" v-model="batchUpdateRequest.foodBatchUpdate.childInheritFieldsAdd" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-add"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="FoodInheritField" v-model="batchUpdateRequest.foodBatchUpdate.childInheritFieldsRemove" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-minus"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="FoodInheritField" v-model="batchUpdateRequest.foodBatchUpdate.childInheritFieldsSet" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-equals"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<v-checkbox :label="$t('RemoveAllType', {type: $t('ChildInheritFields')})" hide-details
|
||||
v-model="batchUpdateRequest.foodBatchUpdate.childInheritFieldsRemoveAll"></v-checkbox>
|
||||
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn :disabled="loading" @click="dialog = false">{{ $t('Cancel') }}</v-btn>
|
||||
<v-btn color="warning" :loading="loading" @click="batchUpdateFoods()" :disabled="updateItems.length < 1">{{ $t('Update') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, ref, watch} from "vue";
|
||||
import {EditorSupportedModels, EditorSupportedTypes, getGenericModelFromString, TFood, TKeyword, TRecipe} from "@/types/Models.ts";
|
||||
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {ApiApi, ApiFoodBatchUpdateUpdateRequest, ApiRecipeBatchUpdateUpdateRequest, Food, Recipe, RecipeOverview} from "@/openapi";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const props = defineProps({
|
||||
items: {type: Array as PropType<Array<Food>>, required: true},
|
||||
activator: {type: String, default: 'parent'},
|
||||
})
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const dialog = defineModel<boolean>({default: false})
|
||||
const loading = ref(false)
|
||||
|
||||
const updateItems = ref([] as Food[])
|
||||
const batchUpdateRequest = ref({foodBatchUpdate: {}} as ApiFoodBatchUpdateUpdateRequest)
|
||||
|
||||
const boolUpdateOptions = ref([
|
||||
{value: true, title: t('Yes')},
|
||||
{value: false, title: t('No')},
|
||||
])
|
||||
|
||||
/**
|
||||
* copy prop when dialog opens so that items remain when parent is updated after change is emitted
|
||||
*/
|
||||
watch(dialog, (newValue, oldValue) => {
|
||||
if (!oldValue && newValue && props.items != undefined) {
|
||||
batchUpdateRequest.value.foodBatchUpdate.foods = props.items.flatMap(r => r.id!)
|
||||
updateItems.value = JSON.parse(JSON.stringify(props.items))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* perform batch request to update recipes
|
||||
*/
|
||||
function batchUpdateFoods() {
|
||||
let api = new ApiApi()
|
||||
loading.value = true
|
||||
|
||||
api.apiFoodBatchUpdateUpdate(batchUpdateRequest.value).then(r => {
|
||||
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
|
||||
}).finally(() => {
|
||||
emit('change')
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@
|
||||
<v-list-item link title="Space" @click="window = 'space'" prepend-icon="fa-solid fa-database"></v-list-item>
|
||||
<v-list-item link :title="$t('Recipes')" @click="window = 'recipes'" prepend-icon="$recipes"></v-list-item>
|
||||
<v-list-item link :title="$t('Import')" @click="window = 'import'" prepend-icon="$import"></v-list-item>
|
||||
<v-list-item link :title="$t('AI')" @click="window = 'ai'" prepend-icon="$ai"></v-list-item>
|
||||
<v-list-item link :title="$t('Unit')" @click="window = 'unit'" prepend-icon="fa-solid fa-scale-balanced"></v-list-item>
|
||||
<v-list-item link :title="$t('Food')" @click="window = 'food'" prepend-icon="fa-solid fa-carrot"></v-list-item>
|
||||
<v-list-item link :title="$t('Keyword')" @click="window = 'keyword'" prepend-icon="fa-solid fa-tags"></v-list-item>
|
||||
@@ -45,7 +46,7 @@
|
||||
<v-btn class="mt-2 ms-2" color="info" href="https://github.com/TandoorRecipes/recipes" target="_blank" prepend-icon="fa-solid fa-code-branch">GitHub
|
||||
</v-btn>
|
||||
|
||||
<v-alert class="mt-3" border="start" variant="tonal" color="success">
|
||||
<v-alert class="mt-3" border="start" variant="tonal" color="success" v-if="(!useUserPreferenceStore().serverSettings.hosted && !useUserPreferenceStore().activeSpace.demo)">
|
||||
<v-alert-title>Did you know?</v-alert-title>
|
||||
Tandoor is Open Source and available to anyone for free to host on their own server. Thousands of hours have been spend
|
||||
making Tandoor what it is today. You can help make Tandoor even better by contributing or helping financing the effort.
|
||||
@@ -105,6 +106,35 @@
|
||||
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="$import" class="me-2" :to="{name: 'RecipeImportPage'}">{{ $t('Import') }}</v-btn>
|
||||
|
||||
</v-window-item>
|
||||
<v-window-item value="ai">
|
||||
<p class="mt-3">Tandoor has several functions that allow you to use AI to automatically perform certain tasks like importing recipes from a PDFs or images.
|
||||
</p>
|
||||
|
||||
<p class="mt-3" v-if="useUserPreferenceStore().serverSettings.hosted">
|
||||
To use AI you must first configure an AI Provider. This can also be done globally for all spaces by the person operating your Tandoor Server.
|
||||
</p>
|
||||
<p class="mt-3" v-if="!useUserPreferenceStore().serverSettings.hosted">
|
||||
Some AI Providers are available globally for every space to use. You can also configure additional AI Providers for your space only.
|
||||
</p>
|
||||
|
||||
<p class="mt-3" v-if="useUserPreferenceStore().serverSettings.hosted">
|
||||
To prevent accidental AI cost you can review your AI usage using the AI Log. The Server Administrator can also set AI usage limits for your space (either monthly or using a balance).
|
||||
</p>
|
||||
<p class="mt-3" v-if="!useUserPreferenceStore().serverSettings.hosted">
|
||||
Depending on your subscription you will have different AI Credits available for your space every month. Additionally you might have a Credit balance
|
||||
that will be used once your monthly limit is reached.
|
||||
</p>
|
||||
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="$ai" class="me-2" :to="{name: 'ModelListPage', params: {model: 'AiProvider'}}">
|
||||
{{ $t('AiProvider') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="$ai" class="me-2" :to="{name: 'ModelListPage', params: {model: 'AiLog'}}">
|
||||
{{ $t('AiLog') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="$ai" class="me-2" :to="{name: 'SpaceSettings'}">{{ $t('SpaceSettings') }}</v-btn>
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="$import" class="me-2" :to="{name: 'RecipeImportPage'}">{{ $t('Import') }}</v-btn>
|
||||
|
||||
</v-window-item>
|
||||
<v-window-item value="unit">
|
||||
<p class="mt-3">Units allow you to measure how much of something you need in a recipe or on a shopping list.
|
||||
@@ -337,6 +367,7 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {ref} from "vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
|
||||
const drawer = defineModel()
|
||||
const window = ref('start')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
|
||||
<v-row v-if=" props.importLog.importedRecipes != undefined && props.importLog.totalRecipes != undefined">
|
||||
<v-row v-if="props.importLog.importedRecipes != undefined && props.importLog.totalRecipes != undefined">
|
||||
<v-col>
|
||||
<v-progress-linear :model-value="(props.importLog.importedRecipes/props.importLog.totalRecipes)*100" height="24" color="primary">
|
||||
{{ props.importLog.importedRecipes }} / {{ props.importLog.totalRecipes }}
|
||||
@@ -8,9 +8,9 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-row v-if="props.importLog.importedRecipes != undefined && props.importLog.totalRecipes != undefined">
|
||||
<v-col>
|
||||
<v-textarea :model-value="importLog.msg"></v-textarea>
|
||||
<v-textarea :model-value="importLog.msg" max-rows="25" :loading="importLog.running" auto-grow></v-textarea>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
|
||||
@@ -36,11 +36,16 @@
|
||||
<td style="width: 1%; text-wrap: nowrap" class="pa-0 d-print-none" v-if="showCheckbox">
|
||||
<v-checkbox-btn v-model="i.checked" color="success" v-if="!i.isHeader"></v-checkbox-btn>
|
||||
</td>
|
||||
<td style="width: 1%; text-wrap: nowrap" class="pr-1"
|
||||
v-html="calculateFoodAmount(i.amount, props.ingredientFactor, useUserPreferenceStore().userSettings.useFractions)" v-if="!i.noAmount"></td>
|
||||
<td style="width: 1%; text-wrap: nowrap" class="pr-1" v-if="i.noAmount"></td>
|
||||
<!-- display calculated food amount or empty cell -->
|
||||
<td style="width: 1%; text-wrap: nowrap"
|
||||
class="pr-1"
|
||||
v-html="calculateFoodAmount(i.amount, props.ingredientFactor, useUserPreferenceStore().userSettings.useFractions)"
|
||||
v-if="!i.noAmount && i.amount != 0">
|
||||
</td>
|
||||
<td style="width: 1%; text-wrap: nowrap" class="pr-1" v-else></td>
|
||||
|
||||
<td style="width: 1%; text-wrap: nowrap" class="pr-1">
|
||||
<template v-if="i.unit && !i.noAmount"> {{ ingredientToUnitString(i, ingredientFactor) }}</template>
|
||||
<template v-if="i.unit && !i.noAmount && i.amount != 0"> {{ ingredientToUnitString(i, ingredientFactor) }}</template>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="i.food">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<recipe-image :height="itemHeight" :width="itemHeight" :recipe="mealPlan.recipe"></recipe-image>
|
||||
</div>
|
||||
<div class="flex-column flex-grow-0 pa-1">
|
||||
<span class="font-light" :class="{'two-line-text': detailedItems,'one-line-text': !detailedItems,}">
|
||||
<span class="font-light" :class="{'three-line-text': detailedItems,'one-line-text': !detailedItems,}">
|
||||
<i class="fas fa-shopping-cart fa-xs float-left" v-if="mealPlan.shopping"/>
|
||||
{{ itemTitle }}
|
||||
</span>
|
||||
@@ -82,4 +82,13 @@ const itemTitle = computed(() => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.three-line-text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -98,9 +98,9 @@ const planItems = computed(() => {
|
||||
*/
|
||||
const calendarItemHeight = computed(() => {
|
||||
if (lgAndUp.value && useUserPreferenceStore().deviceSettings.mealplan_displayPeriod == 'week') {
|
||||
return '2.6rem'
|
||||
return '3.5rem'
|
||||
} else {
|
||||
return '1.3rem'
|
||||
return '1.6rem'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -131,9 +131,9 @@ function refreshVisiblePeriod(startDateUnknown: boolean) {
|
||||
|
||||
// load backwards to as on initial
|
||||
if (startDateUnknown) {
|
||||
useMealPlanStore().refreshFromAPI(DateTime.fromJSDate(calendarDate.value).minus({days: days}).toJSDate(), DateTime.now().plus({days: days}).toJSDate())
|
||||
useMealPlanStore().refreshFromAPI(DateTime.fromJSDate(calendarDate.value).minus({days: days}).toJSDate(), DateTime.fromJSDate(calendarDate.value).plus({days: days}).toJSDate())
|
||||
} else {
|
||||
useMealPlanStore().refreshFromAPI(calendarDate.value, DateTime.now().plus({days: days}).toJSDate())
|
||||
useMealPlanStore().refreshFromAPI(calendarDate.value, DateTime.fromJSDate(calendarDate.value).plus({days: days}).toJSDate())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,17 @@
|
||||
|
||||
<template v-if="route.name == 'MealPlanPage'">
|
||||
<v-divider></v-divider>
|
||||
<v-list-item prepend-icon="fa-solid fa-calendar-plus" link>
|
||||
{{$t('Auto_Planner')}}
|
||||
<auto-plan-dialog></auto-plan-dialog>
|
||||
</v-list-item>
|
||||
<v-list-subheader>{{$t('Settings')}}</v-list-subheader>
|
||||
<v-list-item>
|
||||
<meal-plan-device-settings></meal-plan-device-settings>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -27,6 +32,7 @@ import {useRoute} from "vue-router";
|
||||
import {getListModels} from "@/types/Models";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import MealPlanDeviceSettings from "@/components/settings/MealPlanDeviceSettings.vue";
|
||||
import AutoPlanDialog from "@/components/dialogs/AutoPlanDialog.vue";
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
|
||||
27
vue3/src/components/display/PrivateRecipeBadge.vue
Normal file
27
vue3/src/components/display/PrivateRecipeBadge.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
<span v-if="props.showText" class="ms-1 me-1">{{ $t('Private_Recipe') }}</span>
|
||||
<v-chip class="me-1 mb-1" :color="props.color" :size="props.size" :variant="props.variant" v-for="u in users" :key="u.id" prepend-icon="fa-solid fa-share-nodes"> {{ u.displayName }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {User} from "@/openapi";
|
||||
import {PropType} from "vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
|
||||
const props = defineProps({
|
||||
showText: {type: Boolean, default: true},
|
||||
users: {type: [] as PropType<Array<User>>, required: false},
|
||||
|
||||
size: {type: String, default: 'x-small'},
|
||||
color: {type: String, default: ''},
|
||||
variant: {type: String as PropType<NonNullable<"tonal" | "flat" | "text" | "elevated" | "outlined" | "plain"> | undefined>, default: 'tonal'},
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -120,6 +120,7 @@ const hasFoodProperties = computed(() => {
|
||||
let propertiesFound = false
|
||||
for (const [key, fp] of Object.entries(recipe.value.foodProperties)) {
|
||||
if (fp.total_value !== 0) {
|
||||
console.log(fp, fp.total_value)
|
||||
propertiesFound = true
|
||||
}
|
||||
}
|
||||
@@ -189,7 +190,7 @@ const dialogProperty = ref<undefined | PropertyWrapper>(undefined)
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (!hasFoodProperties) {
|
||||
if (!hasFoodProperties.value) {
|
||||
sourceSelectedToShow.value = "recipe"
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,40 +1,11 @@
|
||||
<template>
|
||||
|
||||
<v-card class="mt-1" v-if="cookLogs.length > 0">
|
||||
<v-card-title>{{ $t('Activity') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list>
|
||||
<v-list-item v-for="c in cookLogs.sort((a,b) => a.createdAt! > b.createdAt! ? 1 : -1)" :key="c.id">
|
||||
<template #prepend>
|
||||
<v-avatar color="primary">V</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold">{{ c.createdBy.displayName }}
|
||||
<v-rating density="comfortable" size="x-small" color="tandoor" class="float-right" v-model="c.rating" readonly v-if="c.rating != undefined"></v-rating>
|
||||
</v-list-item-title>
|
||||
|
||||
{{ c.comment }}
|
||||
|
||||
<p v-if="c.servings != null && c.servings > 0">
|
||||
{{ c.servings }}
|
||||
<span v-if="recipe.servingsText != ''">{{ recipe.servingsText }}</span>
|
||||
<span v-else-if="c.servings == 1">{{ $t('Serving') }}</span>
|
||||
<span v-else>{{ $t('Servings') }}</span>
|
||||
</p>
|
||||
|
||||
<p class="text-disabled">
|
||||
{{ DateTime.fromJSDate(c.createdAt).toLocaleString(DateTime.DATETIME_SHORT) }}
|
||||
</p>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card class="mt-1 d-print-none" v-if="useUserPreferenceStore().isAuthenticated">
|
||||
<v-card class="mt-1 d-print-none" v-if="useUserPreferenceStore().isAuthenticated" :loading="loading">
|
||||
<v-card-text>
|
||||
<v-textarea :label="$t('Comment')" rows="2" v-model="newCookLog.comment"></v-textarea>
|
||||
<v-row de>
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="4">
|
||||
<v-label>{{$t('Rating')}}</v-label><br/>
|
||||
<v-label>{{ $t('Rating') }}</v-label>
|
||||
<br/>
|
||||
<v-rating v-model="newCookLog.rating" clearable hover density="compact"></v-rating>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
@@ -52,6 +23,47 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<v-card class="mt-1" v-if="cookLogs.length > 0" :loading="loading">
|
||||
<v-card-title>{{ $t('Activity') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list>
|
||||
<v-list-item class="border-t-sm" v-for="c in cookLogs" :key="c.id" :link="c.createdBy.id == useUserPreferenceStore().userSettings?.user.id">
|
||||
<template #prepend>
|
||||
<v-avatar color="primary">{{ c.createdBy.displayName.charAt(0) }}</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold">
|
||||
{{ c.createdBy.displayName }}
|
||||
</v-list-item-title>
|
||||
<span>{{ c.comment }}</span>
|
||||
|
||||
<v-list-item-subtitle class="font-italic mt-1" v-if="c.servings != null && c.servings > 0">
|
||||
|
||||
{{ c.servings }}
|
||||
<span v-if="recipe.servingsText != ''">{{ recipe.servingsText }}</span>
|
||||
<span v-else-if="c.servings == 1">{{ $t('Serving') }}</span>
|
||||
<span v-else>{{ $t('Servings') }}</span>
|
||||
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template #append>
|
||||
<v-list-item-action class="flex-column align-end">
|
||||
<v-rating density="comfortable" size="x-small" color="tandoor" v-model="c.rating" half-increments readonly
|
||||
v-if="c.rating != undefined" style="overflow: hidden"></v-rating>
|
||||
<v-spacer></v-spacer>
|
||||
<v-tooltip location="top" :text="DateTime.fromJSDate(c.createdAt).toLocaleString(DateTime.DATETIME_MED)" v-if="c.createdAt != undefined">
|
||||
<template v-slot:activator="{ props }">
|
||||
<span v-bind="props">{{ DateTime.fromJSDate(c.createdAt).toRelative({style: 'narrow'}) }}</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
</v-list-item-action>
|
||||
</template>
|
||||
<model-edit-dialog model="CookLog" :item="c" v-if="c.createdBy.id == useUserPreferenceStore().userSettings?.user.id" @save="recLoadCookLog(props.recipe.id)" @delete="recLoadCookLog(props.recipe.id)"></model-edit-dialog>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
@@ -63,6 +75,7 @@ import {DateTime} from "luxon";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {VDateInput} from 'vuetify/labs/VDateInput'
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
|
||||
|
||||
const props = defineProps({
|
||||
recipe: {
|
||||
@@ -74,21 +87,31 @@ const props = defineProps({
|
||||
const newCookLog = ref({} as CookLog);
|
||||
|
||||
const cookLogs = ref([] as CookLog[])
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
refreshActivity()
|
||||
recLoadCookLog(props.recipe.id)
|
||||
resetForm()
|
||||
})
|
||||
|
||||
/**
|
||||
* load cook logs from database for given recipe
|
||||
* recursively load cook logs from database for given recipe
|
||||
*/
|
||||
function refreshActivity() {
|
||||
function recLoadCookLog(recipeId: number, page: number = 1) {
|
||||
const api = new ApiApi()
|
||||
api.apiCookLogList({recipe: props.recipe.id}).then(r => {
|
||||
// TODO pagination
|
||||
loading.value = true
|
||||
if(page == 1){
|
||||
cookLogs.value = []
|
||||
}
|
||||
api.apiCookLogList({recipe: props.recipe.id, page: page}).then(r => {
|
||||
if (r.results) {
|
||||
cookLogs.value = r.results
|
||||
cookLogs.value = cookLogs.value.concat(r.results)
|
||||
if (r.next) {
|
||||
recLoadCookLog(recipeId, page + 1)
|
||||
} else {
|
||||
cookLogs.value = cookLogs.value.sort((a, b) => a.createdAt! > b.createdAt! ? 1 : -1)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -97,6 +120,7 @@ function refreshActivity() {
|
||||
* reset new cook log from with proper defaults
|
||||
*/
|
||||
function resetForm() {
|
||||
newCookLog.value = {} as CookLog
|
||||
newCookLog.value.servings = props.recipe.servings
|
||||
newCookLog.value.createdAt = new Date()
|
||||
newCookLog.value.recipe = props.recipe.id!
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
<!-- <p class="text-disabled">{{ props.recipe.createdBy.displayName}}</p>-->
|
||||
<keywords-component variant="outlined" :keywords="props.recipe.keywords" :max-keywords="3" v-if="props.showKeywords">
|
||||
<template #prepend>
|
||||
|
||||
<v-chip class="mb-1 me-1" size="x-small" label variant="outlined" v-if="recipe._private">
|
||||
<private-recipe-badge :show-text="false"></private-recipe-badge>
|
||||
</v-chip>
|
||||
<v-chip class="mb-1 me-1" size="x-small" label variant="outlined" color="info"
|
||||
v-if="props.recipe.internal == false">
|
||||
{{ $t('External') }}
|
||||
@@ -100,6 +104,7 @@ import {Recipe, RecipeOverview} from "@/openapi";
|
||||
import RecipeContextMenu from "@/components/inputs/RecipeContextMenu.vue";
|
||||
import RecipeImage from "@/components/display/RecipeImage.vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import PrivateRecipeBadge from "@/components/display/PrivateRecipeBadge.vue";
|
||||
|
||||
const props = defineProps({
|
||||
recipe: {type: {} as PropType<Recipe | RecipeOverview>, required: true,},
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<recipe-context-menu :recipe="recipe" v-if="useUserPreferenceStore().isAuthenticated"></recipe-context-menu>
|
||||
</v-sheet>
|
||||
<keywords-component variant="flat" class="ms-1" :keywords="recipe.keywords"></keywords-component>
|
||||
<private-recipe-badge :users="recipe.shared" v-if="recipe._private"></private-recipe-badge>
|
||||
<v-rating v-model="recipe.rating" size="x-small" v-if="recipe.rating" half-increments readonly></v-rating>
|
||||
<v-sheet class="ps-2 text-disabled">
|
||||
{{ recipe.description }}
|
||||
@@ -35,8 +36,7 @@
|
||||
</v-card>
|
||||
</v-card>
|
||||
|
||||
<!-- only display values if not all are default (e.g. for external recipes) -->
|
||||
<v-card class="mt-1" v-if="recipe.workingTime != 0 || recipe.waitingTime != 0 || recipe.servings != 1">
|
||||
<v-card class="mt-1">
|
||||
<v-container>
|
||||
<v-row class="text-center text-body-2">
|
||||
<v-col class="pt-1 pb-1">
|
||||
@@ -85,6 +85,8 @@
|
||||
<i>{{ recipe.description }}</i>
|
||||
</p>
|
||||
|
||||
<private-recipe-badge :users="recipe.shared" v-if="recipe._private"></private-recipe-badge>
|
||||
|
||||
<v-rating v-model="recipe.rating" size="x-small" v-if="recipe.rating" readonly></v-rating>
|
||||
|
||||
<keywords-component variant="flat" class="mt-4" :keywords="recipe.keywords"></keywords-component>
|
||||
@@ -119,11 +121,16 @@
|
||||
<template v-if="recipe.filePath">
|
||||
<external-recipe-viewer class="mt-2" :recipe="recipe"></external-recipe-viewer>
|
||||
|
||||
<v-card :title="$t('AI')" prepend-icon="$ai" @click="aiConvertRecipe()" :loading="fileApiLoading || loading" :disabled="fileApiLoading || loading"
|
||||
<v-card :title="$t('AI')" prepend-icon="$ai" :loading="fileApiLoading || loading" :disabled="fileApiLoading || loading || !useUserPreferenceStore().activeSpace.aiEnabled"
|
||||
v-if="!recipe.internal">
|
||||
<v-card-text>
|
||||
Convert the recipe using AI
|
||||
{{$t('ConvertUsingAI')}}
|
||||
|
||||
<model-select model="AiProvider" v-model="selectedAiProvider">
|
||||
<template #append>
|
||||
<v-btn @click="aiConvertRecipe()" icon="fa-solid fa-person-running" color="success"></v-btn>
|
||||
</template>
|
||||
</model-select>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -147,7 +154,7 @@
|
||||
:title="$t('CreatedBy')"
|
||||
:subtitle="recipe.createdBy.displayName"
|
||||
prepend-icon="fa-solid fa-user"
|
||||
:to="{name: 'SearchPage', query: {createdby: recipe.createdBy.id!}}">
|
||||
:to="(useUserPreferenceStore().isAuthenticated) ? {name: 'SearchPage', query: {createdby: recipe.createdBy.id!}}: undefined">
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
@@ -156,7 +163,7 @@
|
||||
:title="$t('Created')"
|
||||
:subtitle="DateTime.fromJSDate(recipe.createdAt).toLocaleString(DateTime.DATETIME_MED)"
|
||||
prepend-icon="$create"
|
||||
:to="{name: 'SearchPage', query: {createdon: DateTime.fromJSDate(recipe.createdAt).toISODate()}}">
|
||||
:to="(useUserPreferenceStore().isAuthenticated) ? {name: 'SearchPage', query: {createdon: DateTime.fromJSDate(recipe.createdAt).toISODate()}} : undefined">
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
@@ -165,7 +172,7 @@
|
||||
:title="$t('Updated')"
|
||||
:subtitle="DateTime.fromJSDate(recipe.updatedAt).toLocaleString(DateTime.DATETIME_MED)"
|
||||
prepend-icon="$edit"
|
||||
:to="{name: 'SearchPage', query: {updatedon: DateTime.fromJSDate(recipe.updatedAt).toISODate()}}">
|
||||
:to="(useUserPreferenceStore().isAuthenticated) ? {name: 'SearchPage', query: {updatedon: DateTime.fromJSDate(recipe.updatedAt).toISODate()}}: undefined">
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3" v-if="recipe.sourceUrl">
|
||||
@@ -189,7 +196,7 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {computed, onBeforeUnmount, onMounted, ref, watch} from 'vue'
|
||||
import {ApiApi, Recipe} from "@/openapi"
|
||||
import {AiProvider, ApiApi, Recipe} from "@/openapi"
|
||||
import NumberScalerDialog from "@/components/inputs/NumberScalerDialog.vue"
|
||||
import StepsOverview from "@/components/display/StepsOverview.vue";
|
||||
import RecipeActivity from "@/components/display/RecipeActivity.vue";
|
||||
@@ -204,6 +211,8 @@ import PropertyView from "@/components/display/PropertyView.vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import {useFileApi} from "@/composables/useFileApi.ts";
|
||||
import PrivateRecipeBadge from "@/components/display/PrivateRecipeBadge.vue";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
|
||||
const {request, release} = useWakeLock()
|
||||
const {doAiImport, fileApiLoading} = useFileApi()
|
||||
@@ -214,6 +223,8 @@ const recipe = defineModel<Recipe>({required: true})
|
||||
const servings = ref(1)
|
||||
const showFullRecipeName = ref(false)
|
||||
|
||||
const selectedAiProvider = ref<undefined | AiProvider>(useUserPreferenceStore().activeSpace.aiDefaultProvider)
|
||||
|
||||
/**
|
||||
* factor for multiplying ingredient amounts based on recipe base servings and user selected servings
|
||||
*/
|
||||
@@ -246,7 +257,7 @@ onBeforeUnmount(() => {
|
||||
function aiConvertRecipe() {
|
||||
let api = new ApiApi()
|
||||
|
||||
doAiImport(null, '', recipe.value.id!).then(r => {
|
||||
doAiImport(selectedAiProvider.value.id!,null, '', recipe.value.id!).then(r => {
|
||||
if (r.recipe) {
|
||||
recipe.value.internal = true
|
||||
recipe.value.steps = r.recipe.steps
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-list-item class="swipe-container border-t-sm" :id="itemContainerId" @touchend="handleSwipe()"
|
||||
<v-list-item class="swipe-container border-t-sm mt-0 mb-0 pt-0 pb-0 pe-0 pa-0" :id="itemContainerId" @touchend="handleSwipe()" @click="dialog = true;"
|
||||
v-if="isShoppingListFoodVisible(props.shoppingListFood, useUserPreferenceStore().deviceSettings)"
|
||||
>
|
||||
<!-- <div class="swipe-action" :class="{'bg-success': !isChecked , 'bg-warning': isChecked }">-->
|
||||
@@ -7,34 +7,36 @@
|
||||
<!-- </div>-->
|
||||
|
||||
|
||||
<div class="flex-grow-1 p-2" @click="dialog = true;">
|
||||
<div class="flex-grow-1 p-2">
|
||||
<div class="d-flex">
|
||||
<div class="d-flex flex-column pr-2">
|
||||
<div class="d-flex flex-column pr-2 pl-4">
|
||||
<span v-for="a in amounts" v-bind:key="a.key">
|
||||
<span>
|
||||
<i class="fas fa-check text-success fa-fw" v-if="a.checked"></i>
|
||||
<i class="fas fa-clock-rotate-left text-info fa-fw" v-if="a.delayed"></i> <b>
|
||||
<span :class="{'text-disabled': a.checked || a.delayed}" class="text-no-wrap">
|
||||
<span v-if="amounts.length > 1 || (amounts.length == 1 && a.amount != 1)">{{ $n(a.amount) }}</span>
|
||||
<span class="ms-1" v-if="a.unit">{{ a.unit.name }}</span>
|
||||
<span v-if="amounts.length > 1 || (amounts.length == 1 && a.amount != 1) || a.unit">{{ $n(a.amount) }}</span>
|
||||
<span class="ms-1" v-if="a.unit">{{ pluralString(a.unit, a.amount) }}</span>
|
||||
</span>
|
||||
|
||||
</b>
|
||||
</span>
|
||||
<br/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-grow-1 align-self-center">
|
||||
{{ shoppingListFood.food.name }} <br/>
|
||||
{{ pluralString(shoppingListFood.food, (amounts.length > 1 || (amounts.length == 1 && amounts[0].amount > 1) ? 2 : 1)) }} <br/>
|
||||
<span v-if="infoRow"><small class="text-disabled">{{ infoRow }}</small></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<template v-slot:[checkBtnSlot]>
|
||||
<v-btn color="success" @click.native.stop="useShoppingStore().setEntriesCheckedState(entries, !isChecked, true);"
|
||||
:class="{'btn-success': !isChecked, 'btn-warning': isChecked}" :icon="actionButtonIcon" variant="plain">
|
||||
</v-btn>
|
||||
<div class="ps-3 pe-3" @click.native.stop="useShoppingStore().setEntriesCheckedState(entries, !isChecked, true);">
|
||||
<v-btn color="success" size="large"
|
||||
:class="{'btn-success': !isChecked, 'btn-warning': isChecked}" :icon="actionButtonIcon" variant="plain">
|
||||
</v-btn>
|
||||
</div>
|
||||
<!-- <i class="d-print-none fa-fw fas" :class="{'fa-check': !isChecked , 'fa-cart-plus': isChecked }"></i>-->
|
||||
</template>
|
||||
|
||||
@@ -59,6 +61,7 @@ import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {IShoppingListFood, ShoppingLineAmount} from "@/types/Shopping";
|
||||
import {isDelayed, isEntryVisible, isShoppingListFoodDelayed, isShoppingListFoodVisible} from "@/utils/logic_utils";
|
||||
import ShoppingLineItemDialog from "@/components/dialogs/ShoppingLineItemDialog.vue";
|
||||
import {pluralString} from "@/utils/model_utils.ts";
|
||||
|
||||
const emit = defineEmits(['clicked'])
|
||||
|
||||
|
||||
72
vue3/src/components/display/SpaceLimitsInfo.vue
Normal file
72
vue3/src/components/display/SpaceLimitsInfo.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<v-row v-if="props.space.name != undefined">
|
||||
<v-col cols="12" md="4">
|
||||
<v-card :to="{name: 'SearchPage'}">
|
||||
<v-card-title><i class="fa-solid fa-book"></i> {{ $t('Recipes') }}</v-card-title>
|
||||
<v-card-text>{{ $n(props.space.recipeCount) }} / {{ props.space.maxRecipes == 0 ? '∞' : $n(props.space.maxRecipes) }}</v-card-text>
|
||||
<v-progress-linear :color="isSpaceAboveRecipeLimit(props.space) ? 'error' : 'success'" height="10"
|
||||
:model-value="(props.space.recipeCount / props.space.maxRecipes) * 100"></v-progress-linear>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-card :to="{name: 'ModelListPage', params: {model: 'UserSpace'}}">
|
||||
|
||||
<v-card-title><i class="fa-solid fa-users"></i> {{ $t('Users') }}</v-card-title>
|
||||
<v-card-text>{{ $n(props.space.userCount) }} / {{ props.space.maxUsers == 0 ? '∞' : $n(props.space.maxUsers) }}</v-card-text>
|
||||
<v-progress-linear :color="isSpaceAboveUserLimit(props.space) ? 'error' : 'success'" height="10"
|
||||
:model-value="(props.space.userCount / props.space.maxUsers) * 100"></v-progress-linear>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-card :to="{name: 'ModelListPage', params: {model: 'UserFile'}}">
|
||||
<v-card-title><i class="fa-solid fa-file"></i> {{ $t('Files') }}</v-card-title>
|
||||
<v-card-text v-if="props.space.maxFileStorageMb > -1">{{ $n(Math.round(props.space.fileSizeMb)) }} /
|
||||
{{ props.space.maxFileStorageMb == 0 ? '∞' : $n(props.space.maxFileStorageMb) }}
|
||||
MB
|
||||
</v-card-text>
|
||||
<v-card-text v-if="props.space.maxFileStorageMb == -1">{{ $t('file_upload_disabled') }}</v-card-text>
|
||||
<v-progress-linear v-if="props.space.maxFileStorageMb > -1" :color="isSpaceAboveStorageLimit(props.space) ? 'error' : 'success'" height="10"
|
||||
:model-value="(props.space.fileSizeMb / props.space.maxFileStorageMb) * 100"></v-progress-linear>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card :to="{name: 'ModelListPage', params: {model: 'AiLog'}}">
|
||||
<v-card-title><i class="fa-solid hand-holding-dollar"></i> {{ $t('MonthlyCredits') }}</v-card-title>
|
||||
<v-card-text>{{ $n(props.space.aiMonthlyCreditsUsed) }} / {{ $n(props.space.aiCreditsMonthly) }} {{ $t('Credits') }}
|
||||
</v-card-text>
|
||||
<v-progress-linear :model-value="props.space.aiMonthlyCreditsUsed" :max="props.space.aiCreditsMonthly" height="10"
|
||||
></v-progress-linear>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card :to="{name: 'ModelListPage', params: {model: 'AiLog'}}">
|
||||
<v-card-title><i class="fa-solid hand-holding-dollar"></i> {{ $t('AiCreditsBalance') }}</v-card-title>
|
||||
<v-card-text>{{ $n(props.space.aiCreditsBalance) }} {{ $t('Credits') }}
|
||||
</v-card-text>
|
||||
<v-progress-linear height="10"
|
||||
></v-progress-linear>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {PropType} from "vue";
|
||||
import {Space} from "@/openapi";
|
||||
import {isSpaceAboveRecipeLimit, isSpaceAboveStorageLimit, isSpaceAboveUserLimit} from "@/utils/logic_utils.ts";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
|
||||
const props = defineProps({
|
||||
space: {type: {} as PropType<Space>, required: true},
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,10 +1,19 @@
|
||||
<template>
|
||||
<v-expansion-panels>
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title><i class="far fa-list-alt fa-fw me-2"></i> {{ $t('StepsOverview') }}</v-expansion-panel-title>
|
||||
<v-expansion-panel-title>
|
||||
<i class="far fa-list-alt fa-fw me-2"></i> {{ $t('StepsOverview') }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-container>
|
||||
<v-row v-for="(s, i) in props.steps">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-btn-toggle density="compact" v-model="useUserPreferenceStore().deviceSettings.recipe_mergeStepOverview" border divided>
|
||||
<v-btn :value="false" prepend-icon="fa-solid fa-folder-tree">{{ $t('Structured') }}</v-btn>
|
||||
<v-btn :value="true" prepend-icon="fa-solid fa-arrows-to-circle">{{ $t('Summary') }}</v-btn>
|
||||
</v-btn-toggle>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-for="(s, i) in props.steps" v-if="!useUserPreferenceStore().deviceSettings.recipe_mergeStepOverview">
|
||||
<v-col class="pa-1" cols="12" md="6">
|
||||
<b v-if="s.showAsHeader">{{ i + 1 }}. {{ s.name }} </b>
|
||||
<ingredients-table v-model="s.ingredients" :ingredient-factor="props.ingredientFactor"></ingredients-table>
|
||||
@@ -21,7 +30,13 @@
|
||||
</template>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-row v-if="useUserPreferenceStore().deviceSettings.recipe_mergeStepOverview">
|
||||
<v-col class="pa-1" cols="12" md="6">
|
||||
<ingredients-table v-model="mergedIngredients" :ingredient-factor="props.ingredientFactor" :show-checkbox="false"></ingredients-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
|
||||
@@ -30,10 +45,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {PropType} from 'vue'
|
||||
import {Step} from "@/openapi";
|
||||
import {computed, PropType, ref} from 'vue'
|
||||
import {Ingredient, Step} from "@/openapi";
|
||||
import IngredientsTable from "@/components/display/IngredientsTable.vue";
|
||||
import StepView from "@/components/display/StepView.vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
|
||||
const props = defineProps({
|
||||
steps: {
|
||||
@@ -46,6 +61,70 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const showMergedIngredients = ref(false)
|
||||
|
||||
const mergedIngredients = computed(() => {
|
||||
// Function to collect all ingredients from recipe steps
|
||||
const getAllIngredients = () => {
|
||||
const ingredients: Array<Ingredient> = [];
|
||||
|
||||
// Add ingredients from steps
|
||||
props.steps.forEach(step => {
|
||||
step.ingredients.forEach(ingredient => {
|
||||
if (ingredient.food && !ingredient.isHeader ) {
|
||||
ingredients.push(ingredient);
|
||||
}
|
||||
});
|
||||
|
||||
// Add ingredients from step recipes if they exist
|
||||
if (step.stepRecipeData) {
|
||||
step.stepRecipeData.steps?.forEach((subStep: Step) => {
|
||||
subStep.ingredients.forEach((ingredient: Ingredient) => {
|
||||
if (ingredient.food && !ingredient.isHeader) {
|
||||
ingredients.push(ingredient);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return ingredients;
|
||||
};
|
||||
|
||||
// Get all ingredients
|
||||
const allIngredients = getAllIngredients();
|
||||
|
||||
// Create a map to group and sum ingredients by food and unit
|
||||
const groupedIngredients = new Map<string, Ingredient>();
|
||||
|
||||
allIngredients.forEach(ingredient => {
|
||||
if (!ingredient.food || !ingredient.unit) return;
|
||||
|
||||
// Create a unique key for food-unit combination
|
||||
const key = `${ingredient.food.id}-${ingredient.unit.id}`;
|
||||
|
||||
if (groupedIngredients.has(key)) {
|
||||
// If this food-unit combination already exists, sum the amounts
|
||||
const existingIngredient = groupedIngredients.get(key)!;
|
||||
existingIngredient.amount += ingredient.amount;
|
||||
} else {
|
||||
// Create a new entry with the adjusted amount
|
||||
const clonedIngredient = {...ingredient};
|
||||
groupedIngredients.set(key, clonedIngredient);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert the map back to an array
|
||||
const result = Array.from(groupedIngredients.values());
|
||||
|
||||
// Sort alphabetically by food name
|
||||
return result.sort((a, b) => {
|
||||
const foodNameA = a.food?.name.toLowerCase() || '';
|
||||
const foodNameB = b.food?.name.toLowerCase() || '';
|
||||
return foodNameA.localeCompare(foodNameB);
|
||||
});
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
47
vue3/src/components/display/ThankYouNote.vue
Normal file
47
vue3/src/components/display/ThankYouNote.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<v-alert color="primary" variant="tonal" v-if="useUserPreferenceStore().serverSettings.hosted">
|
||||
<v-alert-title>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-avatar image="../../assets/logo_color.svg" class="me-2"></v-avatar>
|
||||
{{ $t('ThankYou') }}!
|
||||
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn color="primary" class="float-right" href="https://tandoor.dev/manage" target="_blank">{{ $t('ManageSubscription') }}</v-btn>
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-alert-title>
|
||||
<p class="mt-2">{{ $t('ThanksTextHosted') }}</p>
|
||||
</v-alert>
|
||||
|
||||
<v-alert color="primary" variant="tonal" v-if="!useUserPreferenceStore().serverSettings.hosted">
|
||||
<v-alert-title>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-avatar image="../../assets/logo_color.svg" class="me-2"></v-avatar>
|
||||
{{ $t('ThankYou') }}!
|
||||
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn color="primary" class="float-right" href="https://github.com/sponsors/vabene1111" target="_blank"><i class="fa-brands fa-github"></i> GitHub Sponsors
|
||||
</v-btn>
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-alert-title>
|
||||
<p class="mt-2">{{ $t('ThanksTextSelfhosted') }}</p>
|
||||
</v-alert>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -15,6 +15,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
const emit = defineEmits(['stop'])
|
||||
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
@@ -24,6 +26,8 @@ const props = defineProps({
|
||||
seconds: {type: Number, required: true}
|
||||
})
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const initialDurationSeconds = ref(props.seconds)
|
||||
const durationSeconds = ref(initialDurationSeconds.value)
|
||||
const timerRunning = ref(true)
|
||||
|
||||
51
vue3/src/components/inputs/LanguageSelect.vue
Normal file
51
vue3/src/components/inputs/LanguageSelect.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<v-select
|
||||
:label="$t('Language')"
|
||||
v-model="$i18n.locale"
|
||||
:items="availableLocalizations"
|
||||
item-title="language"
|
||||
item-value="code"
|
||||
@update:model-value="updateLanguage()"
|
||||
></v-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, ref} from "vue";
|
||||
import {ApiApi, Localization} from "@/openapi";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
const availableLocalizations = ref([] as Localization[])
|
||||
const {locale} = useI18n()
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
const api = new ApiApi()
|
||||
|
||||
api.apiLocalizationList().then(r => {
|
||||
availableLocalizations.value = r
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* update the django language cookie
|
||||
* this is used by django to inject the language into the template which in turn
|
||||
* sets the frontend language in i18n.ts when the frontend is initialized
|
||||
*/
|
||||
function updateLanguage() {
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + (100 * 365 * 24 * 60 * 60 * 1000));
|
||||
document.cookie = `django_language=${locale.value}; expires=${expires.toUTCString()}; path=/`;
|
||||
location.reload()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -58,7 +58,6 @@
|
||||
<template v-if="hasMoreItems && !loading" #afterlist>
|
||||
<span class="text-disabled font-italic text-caption ms-3">{{ $t('ModelSelectResultsHelp') }}</span>
|
||||
</template>
|
||||
|
||||
</Multiselect>
|
||||
|
||||
<template #append v-if="$slots.append">
|
||||
@@ -73,7 +72,7 @@
|
||||
import {computed, onBeforeMount, onMounted, PropType, ref, useTemplateRef} from "vue"
|
||||
import {EditorSupportedModels, GenericModel, getGenericModelFromString} from "@/types/Models"
|
||||
import Multiselect from '@vueform/multiselect'
|
||||
import {ErrorMessageType, MessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {ErrorMessageType, MessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
const {t} = useI18n()
|
||||
@@ -171,7 +170,7 @@ function search(query: string) {
|
||||
*/
|
||||
async function createObject(object: any, select$: Multiselect) {
|
||||
return await modelClass.value.create({name: object[itemLabel.value]}).then((createdObj: any) => {
|
||||
useMessageStore().addMessage(MessageType.SUCCESS, 'Created', 5000, createdObj)
|
||||
useMessageStore().addPreparedMessage(PreparedMessage.CREATE_SUCCESS, createdObj)
|
||||
emit('create', object)
|
||||
return createdObj
|
||||
}).catch((err: any) => {
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
<v-list-item :to="{ name: 'ModelEditPage', params: {model: 'recipe', id: recipe.id} }" prepend-icon="$edit">
|
||||
{{ $t('Edit') }}
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="$mealplan" link>
|
||||
<v-list-item prepend-icon="$mealplan" @click="mealPlanDialog = true">
|
||||
{{ $t('Add_to_Plan') }}
|
||||
<model-edit-dialog model="MealPlan" :itemDefaults="{recipe: recipe, servings: recipe.servings}"></model-edit-dialog>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="$shopping" link>
|
||||
{{ $t('Add_to_Shopping') }}
|
||||
@@ -30,11 +29,12 @@
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
|
||||
<model-edit-dialog model="MealPlan" :itemDefaults="{recipe: recipe, servings: recipe.servings}" :close-after-create="false" :close-after-save="false" v-model="mealPlanDialog"></model-edit-dialog>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {nextTick, PropType} from 'vue'
|
||||
import {nextTick, PropType, ref} from 'vue'
|
||||
import {Recipe, RecipeFlat, RecipeOverview} from "@/openapi";
|
||||
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
|
||||
import RecipeShareDialog from "@/components/dialogs/RecipeShareDialog.vue";
|
||||
@@ -46,6 +46,8 @@ const props = defineProps({
|
||||
size: {type: String, default: 'medium'},
|
||||
})
|
||||
|
||||
const mealPlanDialog = ref(false)
|
||||
|
||||
function openPrintView() {
|
||||
print()
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<div v-if="!mobile">
|
||||
<vue-draggable v-model="step.ingredients" handle=".drag-handle" :on-sort="sortIngredients" :empty-insert-threshold="25" group="ingredients">
|
||||
<v-row v-for="(ingredient, index) in step.ingredients" :key="ingredient.id" dense>
|
||||
<v-col cols="12" class="pa-0 ma-0 text-center text-disabled">
|
||||
<v-col cols="12" class="pa-0 ma-0 text-center text-disabled" v-if="ingredient.originalText">
|
||||
<v-icon icon="$import" size="x-small"></v-icon>
|
||||
{{ ingredient.originalText }}
|
||||
</v-col>
|
||||
@@ -306,6 +306,7 @@ function parseAndInsertIngredients() {
|
||||
r.forEach(i => {
|
||||
console.log(i)
|
||||
step.value.ingredients.push({
|
||||
originalText: i.value.originalText,
|
||||
amount: i.value.amount,
|
||||
food: i.value.food,
|
||||
unit: i.value.unit,
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {ApiApi, UserFile, UserFileFromJSON} from "@/openapi";
|
||||
import {onMounted, ref} from "vue";
|
||||
import {onMounted, ref, watch} from "vue";
|
||||
import {DateTime} from "luxon";
|
||||
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
|
||||
import {getCookie} from "@/utils/cookie";
|
||||
@@ -131,8 +131,13 @@ const tableHeaders = ref([
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
//TODO move to open function of file tab
|
||||
loadFiles()
|
||||
|
||||
})
|
||||
|
||||
watch(() => dialog.value, (value, oldValue) => {
|
||||
if (value && !oldValue) {
|
||||
loadFiles()
|
||||
}
|
||||
})
|
||||
|
||||
function loadFiles() {
|
||||
|
||||
105
vue3/src/components/model_editors/AiProviderEditor.vue
Normal file
105
vue3/src/components/model_editors/AiProviderEditor.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<model-editor-base
|
||||
:loading="loading"
|
||||
:dialog="dialog"
|
||||
@save="saveObject"
|
||||
@delete="deleteObject"
|
||||
@close="emit('close'); editingObjChanged = false"
|
||||
:is-update="isUpdate()"
|
||||
:is-changed="editingObjChanged"
|
||||
:model-class="modelClass"
|
||||
:object-name="editingObjName()">
|
||||
<v-card-text>
|
||||
<v-form :disabled="loading">
|
||||
<v-text-field :label="$t('Name')" v-model="editingObj.name"></v-text-field>
|
||||
<v-textarea :label="$t('Description')" v-model="editingObj.description"></v-textarea>
|
||||
|
||||
|
||||
<v-text-field :label="$t('APIKey')" v-model="editingObj.apiKey"></v-text-field>
|
||||
|
||||
<v-combobox :label="$t('Model')" :items="aiModels" v-model="editingObj.modelName" hide-details>
|
||||
|
||||
</v-combobox>
|
||||
|
||||
<p class="mt-2 mb-2">{{ $t('AiModelHelp') }} <a href="https://docs.litellm.ai/docs/providers" target="_blank">LiteLLM</a></p>
|
||||
|
||||
<v-checkbox :label="$t('LogCredits')" :hint="$t('LogCreditsHelp')" v-model="editingObj.logCreditCost" v-if="useUserPreferenceStore().userSettings.user.isSuperuser" persistent-hint
|
||||
class="mb-2"></v-checkbox>
|
||||
<v-text-field :label="$t('Url')" v-model="editingObj.url"></v-text-field>
|
||||
|
||||
<v-checkbox :label="$t('Global')" :hint="$t('GlobalHelp')" v-model="globalProvider" v-if="useUserPreferenceStore().userSettings.user.isSuperuser" persistent-hint
|
||||
class="mb-2"></v-checkbox>
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</model-editor-base>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, ref, watch} from "vue";
|
||||
import {AiProvider} from "@/openapi";
|
||||
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import editor from "mavon-editor";
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
item: {type: {} as PropType<AiProvider>, required: false, default: null},
|
||||
itemId: {type: [Number, String], required: false, default: undefined},
|
||||
itemDefaults: {type: {} as PropType<AiProvider>, required: false, default: {} as AiProvider},
|
||||
dialog: {type: Boolean, default: false}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
|
||||
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<AiProvider>('AiProvider', emit)
|
||||
|
||||
/**
|
||||
* watch prop changes and re-initialize editor
|
||||
* required to embed editor directly into pages and be able to change item from the outside
|
||||
*/
|
||||
watch([() => props.item, () => props.itemId], () => {
|
||||
initializeEditor()
|
||||
})
|
||||
|
||||
// object specific data (for selects/display)
|
||||
const aiModels = ref(['gemini/gemini-2.5-pro', 'gemini/gemini-2.5-flash', 'gemini/gemini-2.5-flash-lite', 'gpt-5', 'gpt-5-mini', 'gpt-5-nano'])
|
||||
|
||||
const globalProvider = ref(false)
|
||||
|
||||
watch(() => globalProvider.value, () => {
|
||||
if (globalProvider.value) {
|
||||
editingObj.value.space = undefined
|
||||
} else {
|
||||
editingObj.value.space = useUserPreferenceStore().activeSpace.id!
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initializeEditor()
|
||||
})
|
||||
|
||||
/**
|
||||
* component specific state setup logic
|
||||
*/
|
||||
function initializeEditor() {
|
||||
setupState(props.item, props.itemId, {
|
||||
itemDefaults: props.itemDefaults,
|
||||
newItemFunction: () => {
|
||||
editingObj.value.logCreditCost = true
|
||||
editingObj.value.space = useUserPreferenceStore().activeSpace.id!
|
||||
},
|
||||
}).then(() => {
|
||||
globalProvider.value = editingObj.value.space == undefined
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
81
vue3/src/components/model_editors/CookLogEditor.vue
Normal file
81
vue3/src/components/model_editors/CookLogEditor.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<model-editor-base
|
||||
:loading="loading"
|
||||
:dialog="dialog"
|
||||
@save="saveObject"
|
||||
@delete="deleteObject"
|
||||
@close="emit('close'); editingObjChanged = false"
|
||||
:is-update="isUpdate()"
|
||||
:is-changed="editingObjChanged"
|
||||
:model-class="modelClass"
|
||||
:object-name="editingObjName()">
|
||||
<v-card-text>
|
||||
<v-form :disabled="loading">
|
||||
|
||||
<v-textarea :label="$t('Comment')" rows="2" v-model="editingObj.comment"></v-textarea>
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="4">
|
||||
<v-label>{{ $t('Rating') }}</v-label>
|
||||
<br/>
|
||||
<v-rating v-model="editingObj.rating" clearable hover density="compact"></v-rating>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
|
||||
<v-number-input :label="$t('Servings')" v-model="editingObj.servings" :precision="2"></v-number-input>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-date-input :label="$t('Date')" v-model="editingObj.createdAt"></v-date-input>
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</model-editor-base>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, watch} from "vue";
|
||||
import {CookLog} from "@/openapi";
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
item: {type: {} as PropType<CookLog>, required: false, default: null},
|
||||
itemId: {type: [Number, String], required: false, default: undefined},
|
||||
itemDefaults: {type: {} as PropType<CookLog>, required: false, default: {} as CookLog},
|
||||
dialog: {type: Boolean, default: false}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
|
||||
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<CookLog>('CookLog', emit)
|
||||
|
||||
/**
|
||||
* watch prop changes and re-initialize editor
|
||||
* required to embed editor directly into pages and be able to change item from the outside
|
||||
*/
|
||||
watch([() => props.item, () => props.itemId], () => {
|
||||
initializeEditor()
|
||||
})
|
||||
|
||||
// object specific data (for selects/display)
|
||||
|
||||
onMounted(() => {
|
||||
initializeEditor()
|
||||
})
|
||||
|
||||
/**
|
||||
* component specific state setup logic
|
||||
*/
|
||||
function initializeEditor() {
|
||||
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -54,7 +54,7 @@
|
||||
<properties-editor v-model="editingObj.properties" :amount-for="propertiesAmountFor"></properties-editor>
|
||||
|
||||
<!-- TODO remove once append to body for model select is working properly -->
|
||||
<v-spacer style="margin-top: 60px;"></v-spacer>
|
||||
<v-spacer style="margin-top: 80px;"></v-spacer>
|
||||
</v-form>
|
||||
</v-tabs-window-item>
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
</v-card>
|
||||
</v-form>
|
||||
<!-- TODO remove once append to body for model select is working properly -->
|
||||
<v-spacer style="margin-top: 60px;"></v-spacer>
|
||||
<v-spacer style="margin-top: 80px;"></v-spacer>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<v-tabs-window-item value="hierarchy">
|
||||
@@ -119,6 +119,9 @@
|
||||
mode="tags"></ModelSelect>
|
||||
<ModelSelect model="FoodInheritField" v-model="editingObj.childInheritFields" :label="$t('ChildInheritFields')" :hint="$t('ChildInheritFields_help')"
|
||||
mode="tags"></ModelSelect>
|
||||
|
||||
<!-- TODO remove once append to body for model select is working properly -->
|
||||
<v-spacer style="margin-top: 100px;"></v-spacer>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<v-tabs-window-item value="misc">
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<v-date-input :label="$t('Valid Until')" v-model="editingObj.validUntil"></v-date-input>
|
||||
<v-textarea :label="$t('Note')" v-model="editingObj.internalNote"></v-textarea>
|
||||
<v-checkbox :label="$t('Reusable')" v-model="editingObj.reusable"></v-checkbox>
|
||||
<v-text-field :label="$t('Link')" readonly :model-value="inviteLinkUrl(editingObj)">
|
||||
<v-text-field :label="$t('Link')" readonly :model-value="inviteLinkUrl(editingObj)" v-if="isUpdate()">
|
||||
<template #append-inner>
|
||||
<btn-copy variant="plain" color="undefined" :copy-value="inviteLinkUrl(editingObj)"></btn-copy>
|
||||
</template>
|
||||
@@ -37,6 +37,7 @@ import {DateTime} from "luxon";
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
import BtnCopy from "@/components/buttons/BtnCopy.vue";
|
||||
import {useDjangoUrls} from "@/composables/useDjangoUrls.ts";
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
@@ -91,7 +92,7 @@ function initializeEditor(){
|
||||
* @param inviteLink InviteLink object to create url for
|
||||
*/
|
||||
function inviteLinkUrl(inviteLink: InviteLink) {
|
||||
return `${location.protocol}//${location.host}/invite/${inviteLink.uuid}`
|
||||
return useDjangoUrls().getDjangoUrl(`/invite/${inviteLink.uuid}`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -63,8 +63,8 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- <closable-help-alert :text="$t('RecipeStepsHelp')" :action-text="$t('Steps')" @click="tab='steps'"></closable-help-alert>-->
|
||||
<v-btn @click="tab='steps'" class="float-right" variant="tonal" append-icon="fa-solid fa-arrow-right">{{$t('Steps')}} </v-btn>
|
||||
<!-- <closable-help-alert :text="$t('RecipeStepsHelp')" :action-text="$t('Steps')" @click="tab='steps'"></closable-help-alert>-->
|
||||
<v-btn @click="tab='steps'" class="float-right" variant="tonal" append-icon="fa-solid fa-arrow-right">{{ $t('Steps') }}</v-btn>
|
||||
</v-form>
|
||||
|
||||
</v-tabs-window-item>
|
||||
@@ -77,12 +77,19 @@
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col class="text-center">
|
||||
<v-btn-group density="compact">
|
||||
<v-btn-group density="compact" divided border>
|
||||
<v-btn color="success" prepend-icon="fa-solid fa-plus" @click="addStep()">{{ $t('Add_Step') }}</v-btn>
|
||||
<v-btn color="warning" @click="dialogStepManager = true">
|
||||
<v-btn color="warning" @click="dialogStepManager = true" :disabled="editingObj.steps.length < 2">
|
||||
<v-icon icon="fa-solid fa-arrow-down-1-9"></v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn prepend-icon="fa-solid fa-maximize" @click="handleSplitAllSteps" :disabled="editingObj.steps.length < 1"><span
|
||||
v-if="!mobile">{{ $t('Split') }}</span></v-btn>
|
||||
<v-btn prepend-icon="fa-solid fa-minimize" @click="handleMergeAllSteps" :disabled="editingObj.steps.length < 2"><span
|
||||
v-if="!mobile">{{ $t('Merge') }}</span></v-btn>
|
||||
</v-btn-group>
|
||||
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -101,16 +108,25 @@
|
||||
|
||||
<v-text-field :label="$t('Imported_From')" v-model="editingObj.sourceUrl"></v-text-field>
|
||||
<v-checkbox :label="$t('Private_Recipe')" persistent-hint :hint="$t('Private_Recipe_Help')" v-model="editingObj._private"></v-checkbox>
|
||||
<model-select mode="tags" model="User" :label="$t('Share')" persistent-hint v-model="editingObj.shared"
|
||||
<model-select mode="tags" model="User" :label="$t('Share')" persistent-hint v-model="editingObj.shared"
|
||||
append-to-body v-if="editingObj._private"></model-select>
|
||||
|
||||
<div class="mt-2" v-if="editingObj.filePath">
|
||||
{{ $t('ExternalRecipe') }}
|
||||
<v-text-field readonly v-model="editingObj.filePath"></v-text-field>
|
||||
|
||||
<v-btn prepend-icon="$delete" color="error" :loading="loading">{{ $t('delete_title', {type: $t('ExternalRecipe')}) }}
|
||||
<delete-confirm-dialog :object-name="editingObj.filePath" :model-name="$t('ExternalRecipe')" @delete="deleteExternalFile()"></delete-confirm-dialog>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
</v-form>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
</v-card-text>
|
||||
<v-card-text v-if="isSpaceAtRecipeLimit(useUserPreferenceStore().activeSpace)">
|
||||
<v-alert color="warning" icon="fa-solid fa-triangle-exclamation">
|
||||
{{$t('SpaceLimitReached')}}
|
||||
{{ $t('SpaceLimitReached') }}
|
||||
<v-btn color="success" variant="flat" :to="{name: 'SpaceSettings'}">{{ $t('SpaceSettings') }}</v-btn>
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
@@ -138,7 +154,7 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, ref, shallowRef, watch} from "vue";
|
||||
import {Ingredient, Recipe, Step} from "@/openapi";
|
||||
import {ApiApi, Ingredient, Recipe, Step} from "@/openapi";
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
@@ -151,7 +167,9 @@ import ClosableHelpAlert from "@/components/display/ClosableHelpAlert.vue";
|
||||
import {useDisplay} from "vuetify";
|
||||
import {isSpaceAtRecipeLimit} from "@/utils/logic_utils";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import SpaceSettings from "@/components/settings/SpaceSettings.vue";
|
||||
import {mergeAllSteps, splitAllSteps} from "@/utils/step_utils.ts";
|
||||
import DeleteConfirmDialog from "@/components/dialogs/DeleteConfirmDialog.vue";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
@@ -188,7 +206,7 @@ onMounted(() => {
|
||||
/**
|
||||
* component specific state setup logic
|
||||
*/
|
||||
function initializeEditor(){
|
||||
function initializeEditor() {
|
||||
setupState(props.item, props.itemId, {
|
||||
newItemFunction: () => {
|
||||
editingObj.value.steps = [] as Step[]
|
||||
@@ -249,6 +267,33 @@ function deleteStepAtIndex(index: number) {
|
||||
editingObj.value.steps.splice(index, 1)
|
||||
}
|
||||
|
||||
function handleMergeAllSteps(): void {
|
||||
if (editingObj.value.steps) {
|
||||
mergeAllSteps(editingObj.value.steps)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSplitAllSteps(): void {
|
||||
if (editingObj.value.steps) {
|
||||
splitAllSteps(editingObj.value.steps, '\n')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* deletes the external file for the recipe
|
||||
*/
|
||||
function deleteExternalFile() {
|
||||
let api = new ApiApi()
|
||||
loading.value = true
|
||||
api.apiRecipeDeleteExternalPartialUpdate({id: editingObj.value.id!, patchedRecipe: editingObj.value}).then(r => {
|
||||
editingObj.value = r
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.DELETE_ERROR, err)
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
142
vue3/src/components/model_editors/SpaceEditor.vue
Normal file
142
vue3/src/components/model_editors/SpaceEditor.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<model-editor-base
|
||||
:loading="loading"
|
||||
:dialog="dialog"
|
||||
@save="saveObject"
|
||||
@delete="deleteObject"
|
||||
@close="emit('close'); editingObjChanged = false"
|
||||
:is-update="isUpdate()"
|
||||
:is-changed="editingObjChanged"
|
||||
:model-class="modelClass"
|
||||
:object-name="editingObjName()">
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<v-tabs v-model="tab" :disabled="loading" grow>
|
||||
<v-tab value="space">{{ $t('Space') }}</v-tab>
|
||||
<v-tab value="cosmetic">{{ $t('Cosmetic') }}</v-tab>
|
||||
<v-tab value="ai">{{ $t('AI') }}</v-tab>
|
||||
</v-tabs>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text>
|
||||
<v-tabs-window v-model="tab">
|
||||
<v-tabs-window-item value="space">
|
||||
<v-form :disabled="loading">
|
||||
|
||||
<v-text-field :label="$t('Name')" v-model="editingObj.name"></v-text-field>
|
||||
|
||||
<user-file-field v-model="editingObj.image" :label="$t('Image')" :hint="$t('CustomImageHelp')" persistent-hint></user-file-field>
|
||||
|
||||
<v-textarea v-model="editingObj.message" :label="$t('Message')" clearable></v-textarea>
|
||||
|
||||
<space-limits-info :space="editingObj" :show-thank-you="false" v-if="isUpdate()"></space-limits-info>
|
||||
|
||||
</v-form>
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="cosmetic">
|
||||
<v-label class="mt-4">{{ $t('Nav_Color') }}</v-label>
|
||||
<v-color-picker v-model="editingObj.navBgColor" class="mb-4" mode="hex" :modes="['hex']" show-swatches
|
||||
:swatches="[['#ddbf86'],['#b98766'],['#b55e4f'],['#82aa8b'],['#385f84']]"></v-color-picker>
|
||||
<v-btn class="mb-4" @click="editingObj.navBgColor = ''">{{ $t('Reset') }}</v-btn>
|
||||
|
||||
<user-file-field v-model="editingObj.navLogo" :label="$t('Logo')" :hint="$t('CustomNavLogoHelp')" persistent-hint></user-file-field>
|
||||
|
||||
<user-file-field v-model="editingObj.logoColor32" :label="$t('Logo') + ' 32x32px'"></user-file-field>
|
||||
<user-file-field v-model="editingObj.logoColor128" :label="$t('Logo') + ' 128x128px'"></user-file-field>
|
||||
<user-file-field v-model="editingObj.logoColor144" :label="$t('Logo') + ' 144x144px'"></user-file-field>
|
||||
<user-file-field v-model="editingObj.logoColor180" :label="$t('Logo') + ' 180x180px'"></user-file-field>
|
||||
<user-file-field v-model="editingObj.logoColor192" :label="$t('Logo') + ' 192x192px'"></user-file-field>
|
||||
<user-file-field v-model="editingObj.logoColor512" :label="$t('Logo') + ' 512x512px'"></user-file-field>
|
||||
<user-file-field v-model="editingObj.logoColorSvg" :label="$t('Logo') + ' SVG'"></user-file-field>
|
||||
<user-file-field v-model="editingObj.customSpaceTheme" :label="$t('CustomTheme') + ' CSS'"></user-file-field>
|
||||
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="ai">
|
||||
<p class="text-disabled font-italic text-body-2">
|
||||
<span v-if="useUserPreferenceStore().serverSettings.hosted">
|
||||
{{ $t('AISettingsHostedHelp') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('SettingsOnlySuperuser') }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<v-checkbox v-model="editingObj.aiEnabled" :label="$t('Enabled')" :disabled="!useUserPreferenceStore().userSettings.user.isSuperuser" hide-details></v-checkbox>
|
||||
|
||||
<template v-if="editingObj.aiEnabled">
|
||||
<model-select model="AiProvider" :label="$t('Default')" v-model="editingObj.aiDefaultProvider"></model-select>
|
||||
|
||||
<v-number-input v-model="editingObj.aiCreditsMonthly" :precision="2" :label="$t('MonthlyCredits')"
|
||||
:disabled="!useUserPreferenceStore().userSettings.user.isSuperuser"></v-number-input>
|
||||
<v-number-input v-model="editingObj.aiCreditsBalance" :precision="4" :label="$t('AiCreditsBalance')"
|
||||
:disabled="!useUserPreferenceStore().userSettings.user.isSuperuser"></v-number-input>
|
||||
|
||||
</template>
|
||||
</v-tabs-window-item>
|
||||
|
||||
</v-tabs-window>
|
||||
</v-card-text>
|
||||
</model-editor-base>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, ref, watch} from "vue";
|
||||
import {ApiApi, ConnectorConfig, Space} from "@/openapi";
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
import UserFileField from "@/components/inputs/UserFileField.vue";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import editor from "mavon-editor";
|
||||
import SpaceLimitsInfo from "@/components/display/SpaceLimitsInfo.vue";
|
||||
|
||||
const props = defineProps({
|
||||
item: {type: {} as PropType<Space>, required: false, default: null},
|
||||
itemId: {type: [Number, String], required: false, default: undefined},
|
||||
itemDefaults: {type: {} as PropType<Space>, required: false, default: {} as Space},
|
||||
dialog: {type: Boolean, default: false}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
|
||||
const {
|
||||
setupState,
|
||||
deleteObject,
|
||||
saveObject,
|
||||
isUpdate,
|
||||
editingObjName,
|
||||
loading,
|
||||
editingObj,
|
||||
editingObjChanged,
|
||||
modelClass
|
||||
} = useModelEditorFunctions<Space>('Space', emit)
|
||||
|
||||
/**
|
||||
* watch prop changes and re-initialize editor
|
||||
* required to embed editor directly into pages and be able to change item from the outside
|
||||
*/
|
||||
watch([() => props.item, () => props.itemId], () => {
|
||||
initializeEditor()
|
||||
})
|
||||
|
||||
// object specific data (for selects/display)
|
||||
|
||||
const tab = ref("space")
|
||||
|
||||
onMounted(() => {
|
||||
initializeEditor()
|
||||
})
|
||||
|
||||
/**
|
||||
* component specific state setup logic
|
||||
*/
|
||||
function initializeEditor() {
|
||||
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -20,9 +20,9 @@
|
||||
<v-text-field :label="$t('Username')" v-model="editingObj.username" v-if="editingObj.method == 'NEXTCLOUD' || editingObj.method == 'DB'"></v-text-field>
|
||||
|
||||
<v-text-field :label="$t('Password')" :hint="$t('StoragePasswordTokenHelp')" persistent-hint v-model="editingObj.password" v-if="editingObj.method == 'NEXTCLOUD'"></v-text-field>
|
||||
<v-text-field :label="$t('Access_Token')" :hint="$t('StoragePasswordTokenHelp')" persistent-hint v-model="editingObj.token" v-if="editingObj.method == 'DB'"></v-text-field>
|
||||
<v-text-field :label="$t('Access_Token')" :hint="$t('StoragePasswordTokenHelp')" persistent-hint v-model="editingObj.token" v-if="editingObj.method == 'DB'"></v-text-field>
|
||||
|
||||
<v-text-field :label="$t('Path')" v-model="editingObj.path"></v-text-field>
|
||||
<v-text-field :label="$t('Path')" v-model="editingObj.path"></v-text-field>
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
@@ -33,7 +33,7 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, watch} from "vue";
|
||||
import { Storage } from "@/openapi";
|
||||
import {Storage} from "@/openapi";
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
|
||||
@@ -64,7 +64,7 @@ onMounted(() => {
|
||||
/**
|
||||
* component specific state setup logic
|
||||
*/
|
||||
function initializeEditor(){
|
||||
function initializeEditor() {
|
||||
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<v-form>
|
||||
<p class="text-h6">{{ $t('Profile') }}</p>
|
||||
<v-divider class="mb-3"></v-divider>
|
||||
<v-text-field :label="$t('Username')" v-model="user.username" disabled :hint="$t('theUsernameCannotBeChanged')" persistent-hint></v-text-field>
|
||||
|
||||
<thank-you-note></thank-you-note>
|
||||
|
||||
<v-text-field class="mt-3" :label="$t('Username')" v-model="user.username" disabled :hint="$t('theUsernameCannotBeChanged')" persistent-hint></v-text-field>
|
||||
|
||||
<!-- <v-label>Avatar</v-label><br/>-->
|
||||
<!-- <v-avatar class="mt-3 mb-3" style="height: 10vh; width: 10vh" color="info">V</v-avatar> Feature coming in a future Version of Tandoor.-->
|
||||
@@ -39,6 +42,7 @@ import {ApiApi, User} from "@/openapi";
|
||||
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import {useDjangoUrls} from "@/composables/useDjangoUrls";
|
||||
import ThankYouNote from "@/components/display/ThankYouNote.vue";
|
||||
|
||||
const {getDjangoUrl} = useDjangoUrls()
|
||||
|
||||
|
||||
@@ -3,14 +3,7 @@
|
||||
<p class="text-h6">{{ $t('Cosmetic') }}</p>
|
||||
<v-divider class="mb-3"></v-divider>
|
||||
|
||||
<v-select
|
||||
:label="$t('Language')"
|
||||
v-model="$i18n.locale"
|
||||
:items="availableLocalizations"
|
||||
item-title="language"
|
||||
item-value="code"
|
||||
@update:model-value="updateLanguage()"
|
||||
></v-select>
|
||||
<language-select></language-select>
|
||||
|
||||
<v-label>{{$t('Nav_Color')}}</v-label>
|
||||
<v-color-picker v-model="useUserPreferenceStore().userSettings.navBgColor" mode="hex" :modes="['hex']" show-swatches :swatches="[['#ddbf86'],['#b98766'],['#b55e4f'],['#82aa8b'],['#385f84']]"></v-color-picker>
|
||||
@@ -54,10 +47,10 @@ import {ApiApi, Localization} from "@/openapi";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import LanguageSelect from "@/components/inputs/LanguageSelect.vue";
|
||||
|
||||
const {locale, t} = useI18n()
|
||||
const {t} = useI18n()
|
||||
|
||||
const availableLocalizations = ref([] as Localization[])
|
||||
const availableDefaultPages = ref([
|
||||
{page: 'SEARCH', label: t('Search')},
|
||||
{page: 'SHOPPING', label: t('Shopping_list')},
|
||||
@@ -65,29 +58,10 @@ const availableDefaultPages = ref([
|
||||
{page: 'BOOKS', label: t('Books')},
|
||||
])
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
const api = new ApiApi()
|
||||
|
||||
api.apiLocalizationList().then(r => {
|
||||
availableLocalizations.value = r
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* update the django language cookie
|
||||
* this is used by django to inject the language into the template which in turn
|
||||
* sets the frontend language in i18n.ts when the frontend is initialized
|
||||
*/
|
||||
function updateLanguage() {
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + (100 * 365 * 24 * 60 * 60 * 1000));
|
||||
document.cookie = `django_language=${locale.value}; expires=${expires.toUTCString()}; path=/`;
|
||||
location.reload()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<template>
|
||||
<p class="text-h6">{{ $t('Open_Data_Import') }}</p>
|
||||
<p class="text-h4">{{ $t('Open_Data_Import') }}</p>
|
||||
<v-divider></v-divider>
|
||||
<p class="text-subtitle-2">{{ $t('Data_Import_Info') }}</p>
|
||||
<v-btn href="https://github.com/TandoorRecipes/open-tandoor-data" target="_blank" rel="noreferrer nofollow">{{ $t('Learn_More') }}</v-btn>
|
||||
<p class="text-subtitle-1">{{ $t('Data_Import_Info') }} <a href="https://github.com/TandoorRecipes/open-tandoor-data" target="_blank" rel="noreferrer nofollow">{{ $t('Learn_More') }}</a></p>
|
||||
|
||||
<v-select :items="metadata.versions" :label="$t('Language')" class="mt-2" v-model="requestData.selectedVersion" :loading="loading"></v-select>
|
||||
<v-select :items="metadata.versions" :label="$t('Language')" class="mt-4" v-model="requestData.selectedVersion" :loading="loading"></v-select>
|
||||
|
||||
<v-row v-if="requestData.selectedVersion">
|
||||
<v-col>
|
||||
@@ -29,10 +28,10 @@
|
||||
<td>{{ metadata[requestData.selectedVersion][d] }}</td>
|
||||
<td>
|
||||
<template v-if="responseData[d]">
|
||||
<i class="fas fa-plus-circle"></i> {{ responseData[d].totalCreated }} {{ $t('Created') }} <br/>
|
||||
<i class="fas fa-pencil-alt"></i> {{ responseData[d].totalUpdated }} {{ $t('Updated') }} <br/>
|
||||
<i class="fas fa-forward"></i> {{ responseData[d].totalUntouched}} {{ $t('Unchanged') }} <br/>
|
||||
<i class="fas fa-exclamation-circle"></i> {{ responseData[d].totalErrored }} {{ $t('Error') }}
|
||||
<p v-if="responseData[d].totalCreated > 0" ><i class="fas fa-plus-circle"></i> {{ responseData[d].totalCreated }} {{ $t('Created') }}</p>
|
||||
<p v-if="responseData[d].totalUpdated > 0"><i class="fas fa-pencil-alt"></i> {{ responseData[d].totalUpdated }} {{ $t('Updated') }}</p>
|
||||
<p v-if="responseData[d].totalUntouched > 0"><i class="fas fa-forward"></i> {{ responseData[d].totalUntouched }} {{ $t('Unchanged') }}</p>
|
||||
<p v-if="responseData[d].totalErrored > 0"><i class="fas fa-exclamation-circle"></i> {{ responseData[d].totalErrored }} {{ $t('Error') }}</p>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -102,7 +101,6 @@ function importOpenData() {
|
||||
})
|
||||
|
||||
api.apiImportOpenDataCreate({importOpenData: requestData.value}).then(r => {
|
||||
console.log(r)
|
||||
responseData.value = r
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
<template>
|
||||
<v-form>
|
||||
<p class="text-h6">{{ $t('SpaceMembers') }}</p>
|
||||
<v-divider></v-divider>
|
||||
<p class="text-subtitle-2">{{ $t('SpaceMemberHelp') }}</p>
|
||||
|
||||
<v-data-table :items="spaceUserSpaces" :headers="userTableHeaders" density="compact" :hide-default-footer="spaceUserSpaces.length < 10" class="mt-3">
|
||||
<template #item.groups="{item}">
|
||||
<span v-for="g in item.groups">{{ g.name }} </span>
|
||||
</template>
|
||||
|
||||
<template #item.edit="{item}">
|
||||
<v-btn color="edit" size="small" v-if="item.user.id != useUserPreferenceStore().activeSpace.createdBy.id">
|
||||
<v-icon icon="$edit"></v-icon>
|
||||
<model-edit-dialog model="UserSpace" :item="item" @delete="deleteUserSpace(item)" class="mt-2"></model-edit-dialog>
|
||||
</v-btn>
|
||||
<v-chip color="edit" v-else>{{ $t('Owner') }}</v-chip>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<p class="text-h6 mt-3">{{ $t('Invites') }}
|
||||
<v-btn size="small" class="float-right" prepend-icon="$create" color="create">
|
||||
{{ $t('New') }}
|
||||
<model-edit-dialog model="InviteLink" @delete="deleteInviteLink" @create="item => spaceInviteLinks.push(item)" class="mt-2"></model-edit-dialog>
|
||||
</v-btn>
|
||||
</p>
|
||||
<v-divider class="mb-3"></v-divider>
|
||||
|
||||
<v-data-table :items="spaceInviteLinks" :headers="inviteTableHeaders" density="compact" :hide-default-footer="spaceInviteLinks.length < 10">
|
||||
<template #item.reusable="{item}">
|
||||
<v-icon icon="fa-solid fa-check" color="success" v-if="item.reusable"></v-icon>
|
||||
<v-icon icon="fa-solid fa-times" color="error" v-if="!item.reusable"></v-icon>
|
||||
</template>
|
||||
|
||||
<template #item.edit="{item}">
|
||||
<btn-copy size="small" :copy-value="inviteLinkUrl(item)" class="me-1"></btn-copy>
|
||||
<v-btn color="edit" size="small">
|
||||
<v-icon icon="$edit"></v-icon>
|
||||
<model-edit-dialog model="InviteLink" :item="item" @delete="deleteInviteLink(item)" class="mt-2"></model-edit-dialog>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
|
||||
import {onMounted, ref} from "vue";
|
||||
import {ApiApi, InviteLink, UserSpace} from "@/openapi";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import BtnCopy from "@/components/buttons/BtnCopy.vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const spaceUserSpaces = ref([] as UserSpace[])
|
||||
const spaceInviteLinks = ref([] as InviteLink[])
|
||||
|
||||
const userTableHeaders = [
|
||||
{title: t('Username'), key: 'user.username'},
|
||||
{title: t('Role'), key: 'groups'},
|
||||
{title: t('Edit'), key: 'edit', align: 'end'},
|
||||
]
|
||||
|
||||
const inviteTableHeaders = [
|
||||
{title: 'ID', key: 'id'},
|
||||
{title: t('Email'), key: 'email'},
|
||||
{title: t('Role'), key: 'group.name'},
|
||||
{title: t('Reusable'), key: 'reusable'},
|
||||
{title: t('Edit'), key: 'edit', align: 'end'},
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
const api = new ApiApi()
|
||||
|
||||
api.apiUserSpaceList().then(r => {
|
||||
spaceUserSpaces.value = r.results
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
})
|
||||
|
||||
api.apiInviteLinkList({unused: true}).then(r => {
|
||||
spaceInviteLinks.value = r.results
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* delete userspace from client list (database handled by editor)
|
||||
* @param userSpace UserSpace object that was deleted
|
||||
*/
|
||||
function deleteUserSpace(userSpace: UserSpace) {
|
||||
spaceUserSpaces.value.splice(spaceUserSpaces.value.indexOf(userSpace) - 1, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* delete invite link from client list (database handled by editor)
|
||||
* @param inviteLink InviteLink object that was deleted
|
||||
*/
|
||||
function deleteInviteLink(inviteLink: InviteLink) {
|
||||
spaceInviteLinks.value.splice(spaceInviteLinks.value.indexOf(inviteLink) - 1, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* returns url for invite link
|
||||
* @param inviteLink InviteLink object to create url for
|
||||
*/
|
||||
function inviteLinkUrl(inviteLink: InviteLink) {
|
||||
return `${location.protocol}//${location.host}/invite/${inviteLink.uuid}`
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -3,144 +3,17 @@
|
||||
<p class="text-h6">{{ useUserPreferenceStore().activeSpace.name }}</p>
|
||||
<v-divider class="mb-3"></v-divider>
|
||||
|
||||
<v-row v-if="space.name != undefined">
|
||||
<v-col cols="12" md="4">
|
||||
<v-card>
|
||||
<v-card-title><i class="fa-solid fa-book"></i> {{ $t('Recipes') }}</v-card-title>
|
||||
<v-card-text>{{ $n(space.recipeCount) }} / {{ space.maxRecipes == 0 ? '∞' : $n(space.maxRecipes) }}</v-card-text>
|
||||
<v-progress-linear :color="isSpaceAboveRecipeLimit(space) ? 'error' : 'success'" height="10"
|
||||
:model-value="(space.recipeCount / space.maxRecipes) * 100"></v-progress-linear>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-card>
|
||||
|
||||
<v-card-title><i class="fa-solid fa-users"></i> {{ $t('Users') }}</v-card-title>
|
||||
<v-card-text>{{ $n(space.userCount) }} / {{ space.maxUsers == 0 ? '∞' : $n(space.maxUsers) }}</v-card-text>
|
||||
<v-progress-linear :color="isSpaceAboveUserLimit(space) ? 'error' : 'success'" height="10"
|
||||
:model-value="(space.userCount / space.maxUsers) * 100"></v-progress-linear>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-card>
|
||||
<v-card-title><i class="fa-solid fa-file"></i> {{ $t('Files') }}</v-card-title>
|
||||
<v-card-text v-if="space.maxFileStorageMb > -1">{{ $n(Math.round(space.fileSizeMb)) }} / {{ space.maxFileStorageMb == 0 ? '∞' : $n(space.maxFileStorageMb) }}
|
||||
MB
|
||||
</v-card-text>
|
||||
<v-card-text v-if="space.maxFileStorageMb == -1">{{ $t('file_upload_disabled') }}</v-card-text>
|
||||
<v-progress-linear v-if="space.maxFileStorageMb > -1" :color="isSpaceAboveStorageLimit(space) ? 'error' : 'success'" height="10"
|
||||
:model-value="(space.fileSizeMb / space.maxFileStorageMb) * 100"></v-progress-linear>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-divider class="mt-3 mb-3"></v-divider>
|
||||
|
||||
<v-alert color="primary" variant="tonal" v-if="useUserPreferenceStore().serverSettings.hosted">
|
||||
<v-alert-title>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-avatar image="../../assets/logo_color.svg" class="me-2"></v-avatar>
|
||||
{{ $t('ThankYou') }}!
|
||||
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn color="primary" class="float-right" href="https://tandoor.dev/manage" target="_blank">{{ $t('ManageSubscription') }}</v-btn>
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-alert-title>
|
||||
<p class="mt-2">{{ $t('ThanksTextHosted') }}</p>
|
||||
</v-alert>
|
||||
|
||||
<v-alert color="primary" variant="tonal" v-if="!useUserPreferenceStore().serverSettings.hosted">
|
||||
<v-alert-title>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-avatar image="../../assets/logo_color.svg" class="me-2"></v-avatar>
|
||||
{{ $t('ThankYou') }}!
|
||||
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn color="primary" class="float-right" href="https://github.com/sponsors/vabene1111" target="_blank"><i class="fa-brands fa-github"></i> GitHub Sponsors
|
||||
</v-btn>
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-alert-title>
|
||||
<p class="mt-2">{{ $t('ThanksTextSelfhosted') }}</p>
|
||||
</v-alert>
|
||||
|
||||
|
||||
<p class="text-h6 mt-2">{{ $t('Settings') }}</p>
|
||||
<v-divider class="mb-2"></v-divider>
|
||||
|
||||
<user-file-field v-model="space.image" :label="$t('Image')" :hint="$t('CustomImageHelp')" persistent-hint></user-file-field>
|
||||
|
||||
|
||||
<v-textarea v-model="space.message" :label="$t('Message')"></v-textarea>
|
||||
|
||||
<!-- <model-select v-model="space.foodInherit" model="FoodInheritField" mode="tags"></model-select>-->
|
||||
|
||||
<v-btn color="success" @click="updateSpace()" prepend-icon="$save">{{ $t('Save') }}</v-btn>
|
||||
|
||||
<v-divider class="mt-4 mb-2"></v-divider>
|
||||
<h2>{{$t('Cosmetic')}}</h2>
|
||||
<span>{{$t('Space_Cosmetic_Settings')}}</span>
|
||||
|
||||
<v-label class="mt-4">{{ $t('Nav_Color') }}</v-label>
|
||||
<v-color-picker v-model="space.navBgColor" class="mb-4" mode="hex" :modes="['hex']" show-swatches
|
||||
:swatches="[['#ddbf86'],['#b98766'],['#b55e4f'],['#82aa8b'],['#385f84']]"></v-color-picker>
|
||||
<v-btn class="mb-4" @click="space.navBgColor = ''">{{$t('Reset')}}</v-btn>
|
||||
|
||||
<user-file-field v-model="space.navLogo" :label="$t('Logo')" :hint="$t('CustomNavLogoHelp')" persistent-hint></user-file-field>
|
||||
|
||||
<user-file-field v-model="space.logoColor32" :label="$t('Logo') + ' 32x32px'"></user-file-field>
|
||||
<user-file-field v-model="space.logoColor128" :label="$t('Logo') + ' 128x128px'"></user-file-field>
|
||||
<user-file-field v-model="space.logoColor144" :label="$t('Logo') + ' 144x144px'"></user-file-field>
|
||||
<user-file-field v-model="space.logoColor180" :label="$t('Logo') + ' 180x180px'"></user-file-field>
|
||||
<user-file-field v-model="space.logoColor192" :label="$t('Logo') + ' 192x192px'"></user-file-field>
|
||||
<user-file-field v-model="space.logoColor512" :label="$t('Logo') + ' 512x512px'"></user-file-field>
|
||||
<user-file-field v-model="space.logoColorSvg" :label="$t('Logo') + ' SVG'"></user-file-field>
|
||||
<user-file-field v-model="space.customSpaceTheme" :label="$t('CustomTheme') + ' CSS'"></user-file-field>
|
||||
|
||||
|
||||
<v-btn color="success" @click="updateSpace()" prepend-icon="$save">{{ $t('Save') }}</v-btn>
|
||||
<space-editor :item-id="useUserPreferenceStore().activeSpace.id!"></space-editor>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import {onMounted, ref} from "vue";
|
||||
import {ApiApi, Space} from "@/openapi";
|
||||
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
|
||||
import UserFileField from "@/components/inputs/UserFileField.vue";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
import {isSpaceAboveRecipeLimit, isSpaceAboveStorageLimit, isSpaceAboveUserLimit} from "@/utils/logic_utils";
|
||||
|
||||
const space = ref({} as Space)
|
||||
|
||||
onMounted(() => {
|
||||
let api = new ApiApi()
|
||||
api.apiSpaceCurrentRetrieve().then(r => {
|
||||
space.value = r
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
})
|
||||
})
|
||||
|
||||
function updateSpace() {
|
||||
let api = new ApiApi()
|
||||
api.apiSpacePartialUpdate({id: space.value.id, patchedSpace: space.value}).then(r => {
|
||||
space.value = r
|
||||
useUserPreferenceStore().activeSpace = Object.assign({}, space.value)
|
||||
useMessageStore().addPreparedMessage(PreparedMessage.UPDATE_SUCCESS, space.value)
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
|
||||
})
|
||||
}
|
||||
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import SpaceLimitsInfo from "@/components/display/SpaceLimitsInfo.vue";
|
||||
import SpaceEditor from "@/components/model_editors/SpaceEditor.vue";
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
|
||||
<v-row>
|
||||
<v-col>
|
||||
<p class="text-h6">
|
||||
{{ $t('YourSpaces') }}
|
||||
<v-btn color="create" prepend-icon="$add" class="float-right" size="small" :href="getDjangoUrl('space-overview')">{{$t('New')}}</v-btn>
|
||||
</p>
|
||||
<v-divider></v-divider>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="6" v-for="s in spaces" :key="s.id">
|
||||
<v-card @click="useUserPreferenceStore().switchSpace(s)">
|
||||
<v-img height="200px" cover :src="(s.image !== undefined) ? s.image?.preview : recipeDefaultImage" :alt="$t('Image')"></v-img>
|
||||
<v-card-title>{{ s.name }}
|
||||
<v-chip variant="tonal" density="compact" color="error" v-if="s.id == useUserPreferenceStore().activeSpace.id">{{ $t('active') }}</v-chip>
|
||||
</v-card-title>
|
||||
<v-card-subtitle>{{ $t('created_by') }} {{ s.createdBy.displayName }}</v-card-subtitle>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, ref} from "vue";
|
||||
import {ApiApi, Space} from "@/openapi";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import recipeDefaultImage from '../../assets/recipe_no_image.svg'
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import {useDjangoUrls} from "@/composables/useDjangoUrls";
|
||||
|
||||
const {getDjangoUrl} = useDjangoUrls()
|
||||
|
||||
const spaces = ref([] as Space[])
|
||||
|
||||
onMounted(() => {
|
||||
const api = new ApiApi()
|
||||
|
||||
api.apiSpaceList().then(r => {
|
||||
spaces.value = r.results
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
import {useDjangoUrls} from "@/composables/useDjangoUrls";
|
||||
import {ref} from "vue";
|
||||
import {getCookie} from "@/utils/cookie";
|
||||
import {RecipeFromSourceResponseFromJSON, RecipeImageFromJSON, ResponseError, UserFile, UserFileFromJSON} from "@/openapi";
|
||||
import {AiProvider, RecipeFromSourceResponseFromJSON, RecipeImageFromJSON, ResponseError, UserFile, UserFileFromJSON} from "@/openapi";
|
||||
import {tr} from "vuetify/locale";
|
||||
|
||||
|
||||
/**
|
||||
@@ -86,7 +87,7 @@ export function useFileApi() {
|
||||
* @param text text to import
|
||||
* @param recipeId id of a recipe to use as import base (for external recipes
|
||||
*/
|
||||
function doAiImport(file: File | null, text: string = '', recipeId: string = '') {
|
||||
function doAiImport(providerId: number, file: File | null, text: string = '', recipeId: string = '') {
|
||||
let formData = new FormData()
|
||||
|
||||
if (file != null) {
|
||||
@@ -96,6 +97,7 @@ export function useFileApi() {
|
||||
}
|
||||
formData.append('text', text)
|
||||
formData.append('recipe_id', recipeId)
|
||||
formData.append('ai_provider_id', providerId)
|
||||
fileApiLoading.value = true
|
||||
|
||||
return fetch(getDjangoUrl(`api/ai-import/`), {
|
||||
@@ -116,12 +118,18 @@ export function useFileApi() {
|
||||
* @param files array to import
|
||||
* @param app app to import
|
||||
* @param includeDuplicates if recipes that were found as duplicates should be imported as well
|
||||
* @param mealPlans if meal plans should be imported
|
||||
* @param shoppingLists if shopping lists should be imported
|
||||
* @param nutritionPerServing if nutrition information should be treated as per serving (if false its treated as per recipe)
|
||||
* @returns Promise resolving to the import ID of the app import
|
||||
*/
|
||||
function doAppImport(files: File[], app: string, includeDuplicates: boolean) {
|
||||
function doAppImport(files: File[], app: string, includeDuplicates: boolean, mealPlans: boolean = true, shoppingLists: boolean = true, nutritionPerServing: boolean = false,) {
|
||||
let formData = new FormData()
|
||||
formData.append('type', app);
|
||||
formData.append('duplicates', includeDuplicates ? 'true' : 'false')
|
||||
formData.append('meal_plans', mealPlans ? 'true' : 'false')
|
||||
formData.append('shopping_lists', shoppingLists ? 'true' : 'false')
|
||||
formData.append('nutrition_per_serving', nutritionPerServing ? 'true' : 'false')
|
||||
files.forEach(file => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
@@ -140,4 +148,4 @@ export function useFileApi() {
|
||||
}
|
||||
|
||||
return {fileApiLoading, createOrUpdateUserFile, updateRecipeImage, doAiImport, doAppImport}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"AISettingsHostedHelp": "",
|
||||
"API_Browser": "",
|
||||
"API_Documentation": "",
|
||||
"Active": "",
|
||||
"Add": "",
|
||||
"AddChild": "",
|
||||
"AddFoodToShopping": "",
|
||||
@@ -14,6 +16,12 @@
|
||||
"Added_by": "",
|
||||
"Added_on": "",
|
||||
"Advanced": "",
|
||||
"AiCreditsBalance": "",
|
||||
"AiLog": "",
|
||||
"AiLogHelp": "",
|
||||
"AiModelHelp": "",
|
||||
"AiProvider": "",
|
||||
"AiProviderHelp": "",
|
||||
"App": "",
|
||||
"Apply": "",
|
||||
"Are_You_Sure": "",
|
||||
@@ -44,6 +52,7 @@
|
||||
"Color": "",
|
||||
"Coming_Soon": "",
|
||||
"Completed": "",
|
||||
"ConvertUsingAI": "",
|
||||
"Copy": "",
|
||||
"Copy Link": "",
|
||||
"Copy Token": "",
|
||||
@@ -51,6 +60,8 @@
|
||||
"CountMore": "",
|
||||
"Create": "",
|
||||
"Create Food": "",
|
||||
"CreateFirstRecipe": "",
|
||||
"CreateInvitation": "",
|
||||
"Create_Meal_Plan_Entry": "",
|
||||
"Create_New_Food": "",
|
||||
"Create_New_Keyword": "",
|
||||
@@ -58,6 +69,7 @@
|
||||
"Create_New_Shopping Category": "",
|
||||
"Create_New_Shopping_Category": "",
|
||||
"Create_New_Unit": "",
|
||||
"Credits": "",
|
||||
"Current_Period": "",
|
||||
"Custom Filter": "",
|
||||
"DELETE_ERROR": "",
|
||||
@@ -102,10 +114,14 @@
|
||||
"FoodOnHand": "",
|
||||
"Food_Alias": "",
|
||||
"Foods": "",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "",
|
||||
"FulltextHelp": "",
|
||||
"Fuzzy": "",
|
||||
"FuzzySearchHelp": "",
|
||||
"Global": "",
|
||||
"GlobalHelp": "",
|
||||
"Group": "",
|
||||
"GroupBy": "",
|
||||
"Hide_Food": "",
|
||||
"Hide_Keyword": "",
|
||||
@@ -121,6 +137,9 @@
|
||||
"IgnoredFood": "",
|
||||
"Image": "",
|
||||
"Import": "",
|
||||
"ImportFirstRecipe": "",
|
||||
"ImportMealPlans": "",
|
||||
"ImportShoppingList": "",
|
||||
"Import_Error": "",
|
||||
"Import_Not_Yet_Supported": "",
|
||||
"Import_Result_Info": "",
|
||||
@@ -146,12 +165,16 @@
|
||||
"Keyword": "",
|
||||
"Keyword_Alias": "",
|
||||
"Keywords": "",
|
||||
"LeaveSpace": "",
|
||||
"Link": "",
|
||||
"Load_More": "",
|
||||
"LogCredits": "",
|
||||
"LogCreditsHelp": "",
|
||||
"Log_Cooking": "",
|
||||
"Log_Recipe_Cooking": "",
|
||||
"Make_Header": "",
|
||||
"Make_Ingredient": "",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "",
|
||||
"Meal_Plan": "",
|
||||
"Meal_Plan_Days": "",
|
||||
@@ -164,6 +187,8 @@
|
||||
"Message": "",
|
||||
"MissingProperties": "",
|
||||
"Month": "",
|
||||
"MonthlyCredits": "",
|
||||
"MonthlyCreditsUsed": "",
|
||||
"Move": "",
|
||||
"MoveCategory": "",
|
||||
"Move_Down": "",
|
||||
@@ -192,6 +217,8 @@
|
||||
"NotInShopping": "",
|
||||
"Note": "",
|
||||
"Nutrition": "",
|
||||
"NutritionsPerServing": "",
|
||||
"NutritionsPerServingHelp": "",
|
||||
"OfflineAlert": "",
|
||||
"Ok": "",
|
||||
"OnHand": "",
|
||||
@@ -255,6 +282,7 @@
|
||||
"Selected": "",
|
||||
"Servings": "",
|
||||
"Settings": "",
|
||||
"SettingsOnlySuperuser": "",
|
||||
"Share": "",
|
||||
"Shopping_Categories": "",
|
||||
"Shopping_Category": "",
|
||||
@@ -266,8 +294,13 @@
|
||||
"Show_as_header": "",
|
||||
"Single": "",
|
||||
"Size": "",
|
||||
"Skip": "",
|
||||
"Sort_by_new": "",
|
||||
"Space": "",
|
||||
"SpaceHelp": "",
|
||||
"SpaceMembersHelp": "",
|
||||
"SpaceName": "",
|
||||
"SpacePrivateObjectsHelp": "",
|
||||
"Starting_Day": "",
|
||||
"StartsWith": "",
|
||||
"StartsWithHelp": "",
|
||||
@@ -314,6 +347,8 @@
|
||||
"Website": "",
|
||||
"Week": "",
|
||||
"Week_Numbers": "",
|
||||
"WelcomeSettingsHelp": "",
|
||||
"WelcometoTandoor": "",
|
||||
"Year": "",
|
||||
"Yes": "",
|
||||
"add_keyword": "",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"AISettingsHostedHelp": "",
|
||||
"API_Browser": "",
|
||||
"API_Documentation": "",
|
||||
"Active": "",
|
||||
"Add": "Добави",
|
||||
"AddChild": "",
|
||||
"AddFoodToShopping": "Добавете {food} към списъка си за пазаруване",
|
||||
@@ -14,6 +16,12 @@
|
||||
"Added_by": "Добавено от",
|
||||
"Added_on": "Добавено",
|
||||
"Advanced": "Разширено",
|
||||
"AiCreditsBalance": "",
|
||||
"AiLog": "",
|
||||
"AiLogHelp": "",
|
||||
"AiModelHelp": "",
|
||||
"AiProvider": "",
|
||||
"AiProviderHelp": "",
|
||||
"App": "Приложение",
|
||||
"Apply": "",
|
||||
"Are_You_Sure": "Сигурен ли си?",
|
||||
@@ -44,17 +52,21 @@
|
||||
"Color": "Цвят",
|
||||
"Coming_Soon": "Очаквайте скоро",
|
||||
"Completed": "Завършено",
|
||||
"ConvertUsingAI": "",
|
||||
"Copy": "Копиране",
|
||||
"Copy_template_reference": "Копирайте препратка към шаблона",
|
||||
"CountMore": "...+{count} още",
|
||||
"Create": "Създаване",
|
||||
"Create Food": "Създайте храна",
|
||||
"CreateFirstRecipe": "",
|
||||
"CreateInvitation": "",
|
||||
"Create_Meal_Plan_Entry": "Създайте запис за план за хранене",
|
||||
"Create_New_Food": "Добавете нова храна",
|
||||
"Create_New_Keyword": "Добавяне на нова ключова дума",
|
||||
"Create_New_Meal_Type": "Добавете нов тип хранене",
|
||||
"Create_New_Shopping Category": "Създайте нова категория за пазаруване",
|
||||
"Create_New_Unit": "Добавяне на нова единица",
|
||||
"Credits": "",
|
||||
"Current_Period": "Текущ период",
|
||||
"Custom Filter": "Персонализиран филтър",
|
||||
"DELETE_ERROR": "",
|
||||
@@ -99,10 +111,14 @@
|
||||
"FoodOnHand": "Имате {храна} под ръка.",
|
||||
"Food_Alias": "Псевдоним на храната",
|
||||
"Foods": "Храни",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "",
|
||||
"FulltextHelp": "",
|
||||
"Fuzzy": "",
|
||||
"FuzzySearchHelp": "",
|
||||
"Global": "",
|
||||
"GlobalHelp": "",
|
||||
"Group": "",
|
||||
"GroupBy": "Групирай по",
|
||||
"Hide_Food": "Скриване на храна",
|
||||
"Hide_Keyword": "Скриване на ключови думи",
|
||||
@@ -118,6 +134,9 @@
|
||||
"IgnoredFood": "{food} е настроен да игнорира пазаруването.",
|
||||
"Image": "Изображение",
|
||||
"Import": "Импортиране",
|
||||
"ImportFirstRecipe": "",
|
||||
"ImportMealPlans": "",
|
||||
"ImportShoppingList": "",
|
||||
"Import_Error": "Възникна грешка по време на импортирането ви. Моля, разгънете подробностите в долната част на страницата, за да ги видите.",
|
||||
"Import_Not_Yet_Supported": "Импортирането все още не се поддържа",
|
||||
"Import_Result_Info": "Импортирани са {imported} от {total} рецепти",
|
||||
@@ -141,12 +160,16 @@
|
||||
"Keyword": "Ключова дума",
|
||||
"Keyword_Alias": "Псевдоним на ключова дума",
|
||||
"Keywords": "Ключови думи",
|
||||
"LeaveSpace": "",
|
||||
"Link": "Връзка",
|
||||
"Load_More": "Зареди още",
|
||||
"LogCredits": "",
|
||||
"LogCreditsHelp": "",
|
||||
"Log_Cooking": "Дневник на Готвене",
|
||||
"Log_Recipe_Cooking": "Дневник на Рецепта за готвене",
|
||||
"Make_Header": "Направете заглавие",
|
||||
"Make_Ingredient": "Направете съставка",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Управление на Книги",
|
||||
"Meal_Plan": "План на хранене",
|
||||
"Meal_Plan_Days": "Бъдещи планове за хранене",
|
||||
@@ -158,6 +181,8 @@
|
||||
"Merge_Keyword": "Обединяване на ключова дума",
|
||||
"MissingProperties": "",
|
||||
"Month": "Месец",
|
||||
"MonthlyCredits": "",
|
||||
"MonthlyCreditsUsed": "",
|
||||
"Move": "Премести",
|
||||
"MoveCategory": "Премести към: ",
|
||||
"Move_Down": "Премести надолу",
|
||||
@@ -185,6 +210,8 @@
|
||||
"NotInShopping": "{food} не е в списъка ви за пазаруване.",
|
||||
"Note": "Бележка",
|
||||
"Nutrition": "Хранителни стойности",
|
||||
"NutritionsPerServing": "",
|
||||
"NutritionsPerServingHelp": "",
|
||||
"OfflineAlert": "Вие сте офлайн, списъкът за пазаруване може да не се синхронизира.",
|
||||
"Ok": "Отвори",
|
||||
"OnHand": "В момента под ръка",
|
||||
@@ -248,6 +275,7 @@
|
||||
"Selected": "Избрано",
|
||||
"Servings": "Порции",
|
||||
"Settings": "Настройки",
|
||||
"SettingsOnlySuperuser": "",
|
||||
"Share": "Споделяне",
|
||||
"Shopping_Categories": "Категории за пазаруване",
|
||||
"Shopping_Category": "Категория за пазаруване",
|
||||
@@ -259,8 +287,13 @@
|
||||
"Show_as_header": "Показване като заглавка",
|
||||
"Single": "Единичен",
|
||||
"Size": "Размер",
|
||||
"Skip": "",
|
||||
"Sort_by_new": "Сортиране по ново",
|
||||
"Space": "",
|
||||
"SpaceHelp": "",
|
||||
"SpaceMembersHelp": "",
|
||||
"SpaceName": "",
|
||||
"SpacePrivateObjectsHelp": "",
|
||||
"Starting_Day": "Начален ден от седмицата",
|
||||
"StartsWith": "",
|
||||
"StartsWithHelp": "",
|
||||
@@ -305,6 +338,8 @@
|
||||
"Website": "уебсайт",
|
||||
"Week": "Седмица",
|
||||
"Week_Numbers": "Номера на седмиците",
|
||||
"WelcomeSettingsHelp": "",
|
||||
"WelcometoTandoor": "",
|
||||
"Year": "Година",
|
||||
"Yes": "",
|
||||
"add_keyword": "Добавяне на ключова дума",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"AISettingsHostedHelp": "",
|
||||
"API": "API",
|
||||
"API_Browser": "",
|
||||
"API_Documentation": "",
|
||||
"Account": "Compte",
|
||||
"Active": "",
|
||||
"Add": "Afegir",
|
||||
"AddChild": "",
|
||||
"AddFoodToShopping": "Afegeix {food} a la llista de la compra",
|
||||
@@ -16,6 +18,12 @@
|
||||
"Added_by": "Afegit per",
|
||||
"Added_on": "Afegit el",
|
||||
"Advanced": "Avançat",
|
||||
"AiCreditsBalance": "",
|
||||
"AiLog": "",
|
||||
"AiLogHelp": "",
|
||||
"AiModelHelp": "",
|
||||
"AiProvider": "",
|
||||
"AiProviderHelp": "",
|
||||
"Alignment": "Alineació",
|
||||
"Amount": "Quantitat",
|
||||
"App": "Aplicació",
|
||||
@@ -57,6 +65,7 @@
|
||||
"Comments_setting": "Mostrar comentaris",
|
||||
"Completed": "Completat",
|
||||
"Conversion": "Conversió",
|
||||
"ConvertUsingAI": "",
|
||||
"Copy": "Copiar",
|
||||
"Copy Link": "Copiar Enllaç",
|
||||
"Copy Token": "Copiar Token",
|
||||
@@ -66,6 +75,8 @@
|
||||
"Create": "Crear",
|
||||
"Create Food": "Crear aliment/ingredient",
|
||||
"Create Recipe": "Crear una recepta",
|
||||
"CreateFirstRecipe": "",
|
||||
"CreateInvitation": "",
|
||||
"Create_Meal_Plan_Entry": "Crear una entrada de la planificació d'àpats",
|
||||
"Create_New_Food": "Afegir nou ingredient",
|
||||
"Create_New_Keyword": "Afegir nova Paraula Clau",
|
||||
@@ -74,6 +85,7 @@
|
||||
"Create_New_Shopping_Category": "Afegir nova Categoria de Compres",
|
||||
"Create_New_Unit": "Afegir nova unitat",
|
||||
"Created": "Creada",
|
||||
"Credits": "",
|
||||
"Current_Period": "Període Actual",
|
||||
"Custom Filter": "Filtre Personalitzat",
|
||||
"CustomImageHelp": "Carregar una imatge per mostrar a la vista general de l’espai.",
|
||||
@@ -143,10 +155,14 @@
|
||||
"Food_Alias": "Àlies per l'aliment",
|
||||
"Food_Replace": "Aliment equivalent",
|
||||
"Foods": "Aliments",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "",
|
||||
"FulltextHelp": "",
|
||||
"Fuzzy": "",
|
||||
"FuzzySearchHelp": "",
|
||||
"Global": "",
|
||||
"GlobalHelp": "",
|
||||
"Group": "",
|
||||
"GroupBy": "Agrupat per",
|
||||
"Hide_Food": "Amagar Aliment",
|
||||
"Hide_Keyword": "Amaga les paraules clau",
|
||||
@@ -165,6 +181,9 @@
|
||||
"Image": "Imatge",
|
||||
"Import": "Importar",
|
||||
"Import Recipe": "Importar Recepta",
|
||||
"ImportFirstRecipe": "",
|
||||
"ImportMealPlans": "",
|
||||
"ImportShoppingList": "",
|
||||
"Import_Error": "S'ha produït un error durant la importació. Si us plau, amplia els detalls a la part inferior de la pàgina per veure'l.",
|
||||
"Import_Not_Yet_Supported": "Importació encara no suportada",
|
||||
"Import_Result_Info": "{imported} de {total} receptes s'han importat",
|
||||
@@ -195,13 +214,17 @@
|
||||
"Language": "Llenguatge",
|
||||
"Last_name": "Cognoms",
|
||||
"Learn_More": "Saber-me més",
|
||||
"LeaveSpace": "",
|
||||
"Link": "Enllaç",
|
||||
"Load_More": "Carregueu-ne més",
|
||||
"LogCredits": "",
|
||||
"LogCreditsHelp": "",
|
||||
"Log_Cooking": "Registreu el que s'ha cuinat",
|
||||
"Log_Recipe_Cooking": "Registre de receptes",
|
||||
"Logo": "Logotip",
|
||||
"Make_Header": "Establiu capçalera",
|
||||
"Make_Ingredient": "Establiu ingredient",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Gestioneu els llibres",
|
||||
"Manage_Emails": "Administrar correus",
|
||||
"Meal_Plan": "Pla d'àpats",
|
||||
@@ -215,6 +238,8 @@
|
||||
"Message": "Missatge",
|
||||
"MissingProperties": "",
|
||||
"Month": "Mes",
|
||||
"MonthlyCredits": "",
|
||||
"MonthlyCreditsUsed": "",
|
||||
"Move": "Moure",
|
||||
"MoveCategory": "Moure a: ",
|
||||
"Move_Down": "Moveu avall",
|
||||
@@ -251,6 +276,8 @@
|
||||
"Note": "Nota",
|
||||
"Number of Objects": "Nombre d'Objectes",
|
||||
"Nutrition": "Valors nutricionals",
|
||||
"NutritionsPerServing": "",
|
||||
"NutritionsPerServingHelp": "",
|
||||
"OfflineAlert": "Estàs desconnectat, la llista de la compra no pot actualitzar-se.",
|
||||
"Ok": "Ok",
|
||||
"OnHand": "Ja en tinc",
|
||||
@@ -327,6 +354,7 @@
|
||||
"Selected": "Seleccionat",
|
||||
"Servings": "Racions",
|
||||
"Settings": "Opcions",
|
||||
"SettingsOnlySuperuser": "",
|
||||
"Share": "Compartir",
|
||||
"ShoppingBackgroundSyncWarning": "Error de la connexió, esperant per sincronitzar ...",
|
||||
"Shopping_Categories": "Categoria de compres",
|
||||
@@ -343,9 +371,14 @@
|
||||
"Show_as_header": "Mostreu com a títol",
|
||||
"Single": "Únic/a",
|
||||
"Size": "Mida",
|
||||
"Skip": "",
|
||||
"Social_Authentication": "Identificació amb Xarxes Socials",
|
||||
"Sort_by_new": "Ordenar a partir del més nou",
|
||||
"Space": "",
|
||||
"SpaceHelp": "",
|
||||
"SpaceMembersHelp": "",
|
||||
"SpaceName": "",
|
||||
"SpacePrivateObjectsHelp": "",
|
||||
"Space_Cosmetic_Settings": "Un administrador de l'espai podria canviar algunes configuracions estètiques i tindrien prioritat sobre la configuració dels usuaris per a aquest espai.",
|
||||
"Split_All_Steps": "Dividir totes les files en passos separats.",
|
||||
"StartDate": "Data d'inici",
|
||||
@@ -412,6 +445,8 @@
|
||||
"Week": "Setmana",
|
||||
"Week_Numbers": "Números de la setmana",
|
||||
"Welcome": "Benvingut/da",
|
||||
"WelcomeSettingsHelp": "",
|
||||
"WelcometoTandoor": "",
|
||||
"Year": "Any",
|
||||
"Yes": "",
|
||||
"add_keyword": "Afegir Paraula Clau",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"AISettingsHostedHelp": "",
|
||||
"API": "API",
|
||||
"API_Browser": "",
|
||||
"API_Documentation": "",
|
||||
"Account": "Účet",
|
||||
"Active": "",
|
||||
"Add": "Přidat",
|
||||
"AddChild": "",
|
||||
"AddFoodToShopping": "Přidat {food} na váš nákupní seznam",
|
||||
@@ -16,6 +18,12 @@
|
||||
"Added_by": "Přidáno uživatelem",
|
||||
"Added_on": "Přidáno v",
|
||||
"Advanced": "Rozšířené",
|
||||
"AiCreditsBalance": "",
|
||||
"AiLog": "",
|
||||
"AiLogHelp": "",
|
||||
"AiModelHelp": "",
|
||||
"AiProvider": "",
|
||||
"AiProviderHelp": "",
|
||||
"Alignment": "Zarovnání",
|
||||
"Amount": "Množství",
|
||||
"App": "Aplikace",
|
||||
@@ -57,6 +65,7 @@
|
||||
"Comments_setting": "Zobrazit komentáře",
|
||||
"Completed": "Dokončeno",
|
||||
"Conversion": "Převod",
|
||||
"ConvertUsingAI": "",
|
||||
"Copy": "Kopírovat",
|
||||
"Copy Link": "Kopírovat odkaz",
|
||||
"Copy Token": "Kopírovat token",
|
||||
@@ -66,6 +75,8 @@
|
||||
"Create": "Vytvořit",
|
||||
"Create Food": "Vytvořit potravinu",
|
||||
"Create Recipe": "Vytvořit recept",
|
||||
"CreateFirstRecipe": "",
|
||||
"CreateInvitation": "",
|
||||
"Create_Meal_Plan_Entry": "Vytvořit položku v jídelníčku",
|
||||
"Create_New_Food": "Přidat novou potravinu",
|
||||
"Create_New_Keyword": "Přidat nový štítek",
|
||||
@@ -73,6 +84,7 @@
|
||||
"Create_New_Shopping Category": "Vytvořit novou nákupní kategorii",
|
||||
"Create_New_Shopping_Category": "Přidat novou nákupní kategorii",
|
||||
"Create_New_Unit": "Přidat novou jednotku",
|
||||
"Credits": "",
|
||||
"Current_Period": "Současné období",
|
||||
"Custom Filter": "Uživatelský filtr",
|
||||
"CustomImageHelp": "Nahrajte obrázek, který se zobrazí v přehledu prostoru.",
|
||||
@@ -142,10 +154,14 @@
|
||||
"Food_Alias": "Přezdívka potraviny",
|
||||
"Food_Replace": "Nahrazení v potravině",
|
||||
"Foods": "Potraviny",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "",
|
||||
"FulltextHelp": "",
|
||||
"Fuzzy": "",
|
||||
"FuzzySearchHelp": "",
|
||||
"Global": "",
|
||||
"GlobalHelp": "",
|
||||
"Group": "",
|
||||
"GroupBy": "Seskupit podle",
|
||||
"Hide_Food": "Skrýt potravinu",
|
||||
"Hide_Keyword": "Skrýt štítky",
|
||||
@@ -164,6 +180,9 @@
|
||||
"Image": "Obrázek",
|
||||
"Import": "Import",
|
||||
"Import Recipe": "Importovat recept",
|
||||
"ImportFirstRecipe": "",
|
||||
"ImportMealPlans": "",
|
||||
"ImportShoppingList": "",
|
||||
"Import_Error": "Během importu došlo k chybě. Pro více informací rozbalte Detaily na konci stránky.",
|
||||
"Import_Not_Yet_Supported": "Import není zatím podporován",
|
||||
"Import_Result_Info": "{imported} z {total} receptů naimportováno",
|
||||
@@ -193,13 +212,17 @@
|
||||
"Language": "Jazyk",
|
||||
"Last_name": "Příjmení",
|
||||
"Learn_More": "Zjistit víc",
|
||||
"LeaveSpace": "",
|
||||
"Link": "Odkaz",
|
||||
"Load_More": "Načíst další",
|
||||
"LogCredits": "",
|
||||
"LogCreditsHelp": "",
|
||||
"Log_Cooking": "Zaznamenat vaření",
|
||||
"Log_Recipe_Cooking": "Záznam vaření receptu",
|
||||
"Logo": "Logo",
|
||||
"Make_Header": "Použij jako nadpis",
|
||||
"Make_Ingredient": "Použij jako ingredienci",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Spravovat kuchařky",
|
||||
"Manage_Emails": "Spravovat emaily",
|
||||
"Meal_Plan": "Jídelníček",
|
||||
@@ -213,6 +236,8 @@
|
||||
"Message": "Zpráva",
|
||||
"MissingProperties": "",
|
||||
"Month": "Měsíc",
|
||||
"MonthlyCredits": "",
|
||||
"MonthlyCreditsUsed": "",
|
||||
"Move": "Přesunout",
|
||||
"MoveCategory": "Přesunout do: ",
|
||||
"Move_Down": "Dolů",
|
||||
@@ -248,6 +273,8 @@
|
||||
"Note": "Poznámka",
|
||||
"Number of Objects": "Počet Objektů",
|
||||
"Nutrition": "Výživové hodnoty",
|
||||
"NutritionsPerServing": "",
|
||||
"NutritionsPerServingHelp": "",
|
||||
"OfflineAlert": "Jste offline, nákupní seznam nemusí být synchronizován.",
|
||||
"Ok": "Ok",
|
||||
"OnHand": "Momentálně k dispozici",
|
||||
@@ -324,6 +351,7 @@
|
||||
"Selected": "Vybrané",
|
||||
"Servings": "Porce",
|
||||
"Settings": "Nastavení",
|
||||
"SettingsOnlySuperuser": "",
|
||||
"Share": "Sdílet",
|
||||
"Shopping_Categories": "Kategorie nákupního seznamu",
|
||||
"Shopping_Category": "Kategorie nákupního seznamu",
|
||||
@@ -338,9 +366,14 @@
|
||||
"Show_as_header": "Nastav jako nadpis",
|
||||
"Single": "Jednoduchý",
|
||||
"Size": "Velikost",
|
||||
"Skip": "",
|
||||
"Social_Authentication": "Přihlašování pomocí účtů sociálních sítí",
|
||||
"Sort_by_new": "Seřadit od nejnovějšího",
|
||||
"Space": "",
|
||||
"SpaceHelp": "",
|
||||
"SpaceMembersHelp": "",
|
||||
"SpaceName": "",
|
||||
"SpacePrivateObjectsHelp": "",
|
||||
"Space_Cosmetic_Settings": "Některá kosmetická nastavení mohou měnit správci prostoru a budou mít přednost před nastavením klienta pro daný prostor.",
|
||||
"Split_All_Steps": "Rozdělit každý řádek do samostatného kroku.",
|
||||
"StartDate": "Počáteční datum",
|
||||
@@ -404,6 +437,8 @@
|
||||
"Week": "Týden",
|
||||
"Week_Numbers": "Číslo týdne",
|
||||
"Welcome": "Vítejte",
|
||||
"WelcomeSettingsHelp": "",
|
||||
"WelcometoTandoor": "",
|
||||
"Year": "Rok",
|
||||
"Yes": "",
|
||||
"add_keyword": "Přidat štítek",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"AISettingsHostedHelp": "",
|
||||
"API": "API",
|
||||
"API_Browser": "",
|
||||
"API_Documentation": "",
|
||||
"Account": "Bruger",
|
||||
"Active": "",
|
||||
"Add": "Tilføj",
|
||||
"AddChild": "",
|
||||
"AddFoodToShopping": "Tilføj {food} til indkøbsliste",
|
||||
@@ -16,6 +18,12 @@
|
||||
"Added_by": "Tilføjet af",
|
||||
"Added_on": "Tilføjet den",
|
||||
"Advanced": "Avanceret",
|
||||
"AiCreditsBalance": "",
|
||||
"AiLog": "",
|
||||
"AiLogHelp": "",
|
||||
"AiModelHelp": "",
|
||||
"AiProvider": "",
|
||||
"AiProviderHelp": "",
|
||||
"Alignment": "Justering",
|
||||
"Amount": "Mængde",
|
||||
"App": "App",
|
||||
@@ -57,6 +65,7 @@
|
||||
"Comments_setting": "Vis kommentarer",
|
||||
"Completed": "Afsluttet",
|
||||
"Conversion": "Konversion",
|
||||
"ConvertUsingAI": "",
|
||||
"Copy": "Kopier",
|
||||
"Copy Link": "Kopier link",
|
||||
"Copy Token": "Kopier token",
|
||||
@@ -66,6 +75,8 @@
|
||||
"Create": "Opret",
|
||||
"Create Food": "Opret mad",
|
||||
"Create Recipe": "Opret opskrift",
|
||||
"CreateFirstRecipe": "",
|
||||
"CreateInvitation": "",
|
||||
"Create_Meal_Plan_Entry": "Indsæt punkt i madplan",
|
||||
"Create_New_Food": "Tilføj ny mad",
|
||||
"Create_New_Keyword": "Tilføj nyt nøgleord",
|
||||
@@ -74,6 +85,7 @@
|
||||
"Create_New_Shopping_Category": "Opret ny indkøbskategori",
|
||||
"Create_New_Unit": "Tilføj ny enhed",
|
||||
"Created": "Skabt",
|
||||
"Credits": "",
|
||||
"Current_Period": "Nuværende periode",
|
||||
"Custom Filter": "Tilpasset filter",
|
||||
"CustomImageHelp": "Upload et billede for at vise dets plade i område-oversigten.",
|
||||
@@ -143,10 +155,14 @@
|
||||
"Food_Alias": "Alternativt navn til mad",
|
||||
"Food_Replace": "Erstat ingrediens",
|
||||
"Foods": "Mad",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "",
|
||||
"FulltextHelp": "",
|
||||
"Fuzzy": "",
|
||||
"FuzzySearchHelp": "",
|
||||
"Global": "",
|
||||
"GlobalHelp": "",
|
||||
"Group": "",
|
||||
"GroupBy": "Grupper efter",
|
||||
"Hide_Food": "Skjul mad",
|
||||
"Hide_Keyword": "Skjul nøgleord",
|
||||
@@ -165,6 +181,9 @@
|
||||
"Image": "Billede",
|
||||
"Import": "Importer",
|
||||
"Import Recipe": "Importer opskrift",
|
||||
"ImportFirstRecipe": "",
|
||||
"ImportMealPlans": "",
|
||||
"ImportShoppingList": "",
|
||||
"Import_Error": "Der opstod en fejl under din importering. Udvid detaljerne i bunden af siden for at se fejlen.",
|
||||
"Import_Not_Yet_Supported": "Import endnu ikke understøttet",
|
||||
"Import_Result_Info": "{imported} af {total} opskrifter blev importeret",
|
||||
@@ -195,13 +214,17 @@
|
||||
"Language": "Sprog",
|
||||
"Last_name": "Efternavn",
|
||||
"Learn_More": "Lær mere",
|
||||
"LeaveSpace": "",
|
||||
"Link": "Link",
|
||||
"Load_More": "Indlæs mere",
|
||||
"LogCredits": "",
|
||||
"LogCreditsHelp": "",
|
||||
"Log_Cooking": "Noter tilberedning",
|
||||
"Log_Recipe_Cooking": "Noter tilberedning af opskrift",
|
||||
"Logo": "Logo",
|
||||
"Make_Header": "Opret rubrik",
|
||||
"Make_Ingredient": "Opret ingredient",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Administrer bøger",
|
||||
"Manage_Emails": "Håndter Emails",
|
||||
"Meal_Plan": "Madplan",
|
||||
@@ -215,6 +238,8 @@
|
||||
"Message": "Besked",
|
||||
"MissingProperties": "",
|
||||
"Month": "Måned",
|
||||
"MonthlyCredits": "",
|
||||
"MonthlyCreditsUsed": "",
|
||||
"Move": "Flyt",
|
||||
"MoveCategory": "Flyt til: ",
|
||||
"Move_Down": "Flyt ned",
|
||||
@@ -251,6 +276,8 @@
|
||||
"Note": "Note",
|
||||
"Number of Objects": "Antal objekter",
|
||||
"Nutrition": "Næring",
|
||||
"NutritionsPerServing": "",
|
||||
"NutritionsPerServingHelp": "",
|
||||
"OfflineAlert": "Du er offline, indkøbslisten er måske ikke synkroniseret.",
|
||||
"Ok": "Åben",
|
||||
"OnHand": "Til rådighed",
|
||||
@@ -327,6 +354,7 @@
|
||||
"Selected": "Valgt",
|
||||
"Servings": "Serveringer",
|
||||
"Settings": "Indstillinger",
|
||||
"SettingsOnlySuperuser": "",
|
||||
"Share": "Del",
|
||||
"ShoppingBackgroundSyncWarning": "Dårligt netværk, afventer synkronisering ...",
|
||||
"Shopping_Categories": "Indkøbskategorier",
|
||||
@@ -343,9 +371,14 @@
|
||||
"Show_as_header": "Vis som rubrik",
|
||||
"Single": "Enkel",
|
||||
"Size": "Størrelse",
|
||||
"Skip": "",
|
||||
"Social_Authentication": "Social authenticering",
|
||||
"Sort_by_new": "Sorter efter nylige",
|
||||
"Space": "",
|
||||
"SpaceHelp": "",
|
||||
"SpaceMembersHelp": "",
|
||||
"SpaceName": "",
|
||||
"SpacePrivateObjectsHelp": "",
|
||||
"Space_Cosmetic_Settings": "Visse kosmetiske indstillinger kan ændres af område-administratorer og vil overskrive klient-indstillinger for pågældende område.",
|
||||
"Split_All_Steps": "Opdel rækker i separate trin.",
|
||||
"StartDate": "Startdato",
|
||||
@@ -412,6 +445,8 @@
|
||||
"Week": "Uge",
|
||||
"Week_Numbers": "Ugenumre",
|
||||
"Welcome": "Velkommen",
|
||||
"WelcomeSettingsHelp": "",
|
||||
"WelcometoTandoor": "",
|
||||
"Year": "År",
|
||||
"Yes": "",
|
||||
"add_keyword": "Tilføj nøgleord",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"AI": "AI",
|
||||
"AIImportSubtitle": "Verwende AI um Fotos von Rezepten zu importieren.",
|
||||
"AISettingsHostedHelp": "AI Verfügbarkeit und Credit Limits können über die Tarifverwaltung geändert werden. ",
|
||||
"API": "API",
|
||||
"APIKey": "API Schlüssel",
|
||||
"API_Browser": "API Browser",
|
||||
"API_Documentation": "API Dokumentation",
|
||||
"AccessTokenHelp": "Zugriffsschlüssel für die REST Schnittstelle.",
|
||||
"Access_Token": "Zugriffstoken",
|
||||
"Account": "Konto",
|
||||
"Actions": "Aktionen",
|
||||
"Active": "Aktiv",
|
||||
"Activity": "Aktivität",
|
||||
"Add": "Hinzufügen",
|
||||
"AddAll": "Alle Hinzufügen",
|
||||
@@ -28,6 +31,12 @@
|
||||
"Admin": "Admin",
|
||||
"Advanced": "Erweitert",
|
||||
"Advanced Search Settings": "Erweiterte Sucheinstellungen",
|
||||
"AiCreditsBalance": "Credit Guthaben",
|
||||
"AiLog": "AI Protokoll",
|
||||
"AiLogHelp": "Eine Übersicht der AI Anfragen.",
|
||||
"AiModelHelp": "Die Liste enthält Modelle die offiziell Unterstützt und getestet wurden. Weitere modelle können manuell eingetragen werden.",
|
||||
"AiProvider": "AI Anbieter",
|
||||
"AiProviderHelp": "Je nach Präferenz können verschiedene AI Anbieter angelegt werden. Diese können auch Space übergreifend sein.",
|
||||
"Alignment": "Ausrichtung",
|
||||
"AllRecipes": "Alle Rezepte",
|
||||
"Amount": "Menge",
|
||||
@@ -89,6 +98,7 @@
|
||||
"Continue": "Weiter",
|
||||
"Conversion": "Umrechnung",
|
||||
"ConversionsHelp": "Mit Umrechnungen kann die Menge eines Lebensmittels in verschiedenen Einheiten ausgerechnet werden. Aktuell wird dies nur zur berechnung von Eigenschaften verwendet, später jedoch sollen auch andere Funktionen von Tandoor davon profitieren. ",
|
||||
"ConvertUsingAI": "Mithilfe von AI Umwandeln",
|
||||
"CookLog": "Kochprotokoll",
|
||||
"CookLogHelp": "Einträge im Kochprotokoll für Rezepte. ",
|
||||
"Cooked": "Gekocht",
|
||||
@@ -102,6 +112,8 @@
|
||||
"Create": "Erstellen",
|
||||
"Create Food": "Zutat erstellen",
|
||||
"Create Recipe": "Rezept erstellen",
|
||||
"CreateFirstRecipe": "Erstelle dein erstes Rezept mit dem Rezepteditor.",
|
||||
"CreateInvitation": "Einladung erstellen",
|
||||
"Create_Meal_Plan_Entry": "Neuer Eintrag",
|
||||
"Create_New_Food": "Neues Lebensmittel hinzufügen",
|
||||
"Create_New_Keyword": "Neues Schlagwort hinzufügen",
|
||||
@@ -111,6 +123,7 @@
|
||||
"Create_New_Unit": "Neue Einheit hinzufügen",
|
||||
"Created": "Erstellt",
|
||||
"CreatedBy": "Erstellt von",
|
||||
"Credits": "Credits",
|
||||
"Ctrl+K": "Strg+K",
|
||||
"Current_Period": "Aktueller Zeitraum",
|
||||
"Custom Filter": "Benutzerdefinierter Filter",
|
||||
@@ -204,11 +217,15 @@
|
||||
"Food_Replace": "Essen Ersetzen",
|
||||
"Foods": "Lebensmittel",
|
||||
"Friday": "Freitag",
|
||||
"FromBalance": "Guthaben verwendet",
|
||||
"Fulltext": "Volltext",
|
||||
"FulltextHelp": "Felder welche im Volltext durchsucht werden sollen. Tipp: Die Suchtypen 'web', 'raw' und 'phrase' funktionieren nur mit Volltext-Feldern.",
|
||||
"Fuzzy": "Unscharf",
|
||||
"FuzzySearchHelp": "Verwende unscharfe Suche um Einträge auch bei Unterschieden in der Schreibweise zu finden.",
|
||||
"GettingStarted": "Erste Schritte",
|
||||
"Global": "Global",
|
||||
"GlobalHelp": "Globale AI Anbieter können von Nutzern aller Spaces verwendet werden. Sie können nur dich Instanz Admins (Superusers) erstellt und bearbeitet werden.",
|
||||
"Group": "Gruppe",
|
||||
"GroupBy": "Gruppieren nach",
|
||||
"HeaderWarning": "Achtung: Durch ändern auf Überschrift werden Menge/Einheit/Lebensmittel gelöscht",
|
||||
"Headline": "Überschrift",
|
||||
@@ -234,7 +251,10 @@
|
||||
"Import": "Importieren",
|
||||
"Import Recipe": "Rezept importieren",
|
||||
"ImportAll": "Alle importieren",
|
||||
"ImportFirstRecipe": "Importiere dein erstes Rezept von einer von tausenden Websites oder nutze einen der anderen Importer um bestehende Sammlungen, Dokumente oder URL Listen zu importieren. ",
|
||||
"ImportIntoTandoor": "In Tandoor importieren",
|
||||
"ImportMealPlans": "Speisepläne importieren",
|
||||
"ImportShoppingList": "Einkaufslisten importieren",
|
||||
"Import_Error": "Es ist ein Fehler beim Importieren aufgetreten. Bitte sieh dir die ausgeklappten Details unten auf der Seite an.",
|
||||
"Import_Not_Yet_Supported": "Importieren wird noch nicht unterstützt",
|
||||
"Import_Result_Info": "{imported} von insgesamt {total} Rezepten wurden importiert",
|
||||
@@ -273,16 +293,19 @@
|
||||
"Last": "Letztes",
|
||||
"Last_name": "Nachname",
|
||||
"Learn_More": "Mehr erfahren",
|
||||
"LeaveSpace": "Space verlassen",
|
||||
"Link": "Link",
|
||||
"Load": "Laden",
|
||||
"Load_More": "Weitere laden",
|
||||
"LogCredits": "Credits Protokollieren",
|
||||
"LogCreditsHelp": "Protokolliere die Credit Kosten der AI Anfragen. Ohne diese Protokollierung können Nutzer unbgerenzt viele Anfragen stellen.",
|
||||
"Log_Cooking": "Kochen protokollieren",
|
||||
"Log_Recipe_Cooking": "Kochen protokollieren",
|
||||
"Logo": "Logo",
|
||||
"Logout": "Ausloggen",
|
||||
"Make_Header": "In Überschrift wandeln",
|
||||
"Make_Ingredient": "In Zutat umwandeln",
|
||||
"ManageSubscription": "Tarfi verwalten",
|
||||
"ManageSubscription": "Tarif verwalten",
|
||||
"Manage_Books": "Bücher verwalten",
|
||||
"Manage_Emails": "E-Mails verwalten",
|
||||
"MealPlanHelp": "Ein Speiseplan ist ein Eintrag im Kalender zur Planung von Mahlzeiten. Er muss entweder ein Rezept oder einen Titel erhalten und kann mit der Einkaufsliste verknüpft werden. ",
|
||||
@@ -301,9 +324,12 @@
|
||||
"Miscellaneous": "Sonstige",
|
||||
"MissingConversion": "Fehlende Umrechnung",
|
||||
"MissingProperties": "Fehlende Eigenschaften",
|
||||
"Model": "Modell",
|
||||
"ModelSelectResultsHelp": "Für mehr Ergebnisse suchen",
|
||||
"Monday": "Montag",
|
||||
"Month": "Monat",
|
||||
"MonthlyCredits": "Monatliche Credits",
|
||||
"MonthlyCreditsUsed": "Monatliche Credits verwendet",
|
||||
"More": "Mehr",
|
||||
"Move": "Verschieben",
|
||||
"MoveCategory": "Verschieben nach: ",
|
||||
@@ -345,6 +371,8 @@
|
||||
"Note": "Notiz",
|
||||
"Number of Objects": "Anzahl von Objekten",
|
||||
"Nutrition": "Nährwerte",
|
||||
"NutritionsPerServing": "Nährwerte pro Portion",
|
||||
"NutritionsPerServingHelp": "Manche Anwendungen spezifizieren nicht, ob Nährwerte pro Portion oder pro Rezept anzugeben sind. Standardmäßig werden Sie daher pro Rezept importiert. Wähle diese Option um Sie als pro Portion zu behandeln.",
|
||||
"OfflineAlert": "Du bist offline. Deine Einkaufsliste wird nicht synchronisiert.",
|
||||
"Ok": "Ok",
|
||||
"OnHand": "Aktuell vorrätig",
|
||||
@@ -457,6 +485,7 @@
|
||||
"Servings": "Portionen",
|
||||
"ServingsText": "Portionstext",
|
||||
"Settings": "Einstellungen",
|
||||
"SettingsOnlySuperuser": "Einige Einstellungen können nur vom Server Administrator verändert werden.",
|
||||
"Share": "Teilen",
|
||||
"ShopLater": "Später kaufen",
|
||||
"ShopNow": "Jetzt kaufen",
|
||||
@@ -480,17 +509,21 @@
|
||||
"Show_as_header": "Als Überschrift",
|
||||
"Single": "Einzeln",
|
||||
"Size": "Größe",
|
||||
"Skip": "Überspringen",
|
||||
"Social_Authentication": "Login über Drittanbieter",
|
||||
"Sort_by_new": "Nach Neueste sortieren",
|
||||
"Source": "Quelle",
|
||||
"SourceImportHelp": "Importiere JSON im schema.org/recipe format oder eine HTML Seite mit json+ld Rezept bzw. microdata.",
|
||||
"SourceImportSubtitle": "Importiere JSON oder HTML manuell.",
|
||||
"Space": "Space",
|
||||
"SpaceHelp": "Alle deine Daten sind sicher in deinem Space gespeichert und können nur von dir und den anderen Mitgliedern genutzt werden.",
|
||||
"SpaceLimitExceeded": "Dein Space hat ein Limit überschritten, manche Funktionen wurden eingeschränkt.",
|
||||
"SpaceLimitReached": "Dieser Space hat ein Limit erreicht. Es können keine neuen Objekte von diesem Typ angelegt werden.",
|
||||
"SpaceMemberHelp": "Füge Benutzer hinzu indem du Einladungen erstellst und Sie an die gewünschte Person sendest.",
|
||||
"SpaceMembers": "Space Mitglieder",
|
||||
"SpaceMembersHelp": "Benutzer und Ihre Rechte in einem Space. ",
|
||||
"SpaceMembersHelp": "Benutzer und Ihre Rechte in einem Space. Füge weitere Nutzer mit Einladungslinks hinzu.",
|
||||
"SpaceName": "Space Name",
|
||||
"SpacePrivateObjectsHelp": "Einige Objekte sind Standardmäßig privat, können aber mit Mitgliedern deines Spaces geteilt werden.",
|
||||
"SpaceSettings": "Space Einstellungen",
|
||||
"Space_Cosmetic_Settings": "Kosmetische Einstellungen auf Space Ebene überschreiben die Einstellungen der einzelnen Nutzer.",
|
||||
"Split": "Aufteilen",
|
||||
@@ -511,10 +544,12 @@
|
||||
"Storage": "Externer Speicher",
|
||||
"StorageHelp": "Externe Speicherorte an denen Rezepte als Dateien (Foto/PDF) abgelegt und mit Tandor syncronisiert werden können.",
|
||||
"StoragePasswordTokenHelp": "Das hinterlegte Passwort/Token kann nicht angezeigt werden. Es wird nur aktualisiert wenn etwas neues in das Feld eingegeben wird. ",
|
||||
"Structured": "Strukturiert",
|
||||
"SubstituteOnHand": "Du hast eine Alternative vorrätig.",
|
||||
"Substitutes": "Alternativen",
|
||||
"Success": "Erfolgreich",
|
||||
"SuccessClipboard": "Einkaufsliste wurde in die Zwischenablage kopiert",
|
||||
"Summary": "Zusammenfassung",
|
||||
"Sunday": "Sonntag",
|
||||
"Supermarket": "Supermarkt",
|
||||
"SupermarketCategoriesOnly": "Nur Supermarktkategorien",
|
||||
@@ -600,6 +635,8 @@
|
||||
"Week": "Woche",
|
||||
"Week_Numbers": "Kalenderwochen",
|
||||
"Welcome": "Willkommen",
|
||||
"WelcomeSettingsHelp": "Bitte wähle die grundlegenden Einstellungen für deinen Space. Du kannst Sie später jederzeit in den Einstellungen ändern.",
|
||||
"WelcometoTandoor": "Willkommen bei Tandoor",
|
||||
"WorkingTime": "Arbeitszeit",
|
||||
"Year": "Jahr",
|
||||
"Yes": "Ja",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"AISettingsHostedHelp": "",
|
||||
"API": "API",
|
||||
"API_Browser": "",
|
||||
"API_Documentation": "",
|
||||
"Account": "Λογαριασμός",
|
||||
"Active": "",
|
||||
"Add": "Προσθήκη",
|
||||
"AddChild": "",
|
||||
"AddFoodToShopping": "Προσθήκη του φαγητού {food} στη λίστα αγορών σας",
|
||||
@@ -16,6 +18,12 @@
|
||||
"Added_by": "Προστέθηκε από",
|
||||
"Added_on": "Προστέθηκε στις",
|
||||
"Advanced": "Για προχωρημένους",
|
||||
"AiCreditsBalance": "",
|
||||
"AiLog": "",
|
||||
"AiLogHelp": "",
|
||||
"AiModelHelp": "",
|
||||
"AiProvider": "",
|
||||
"AiProviderHelp": "",
|
||||
"Alignment": "Ευθυγράμμιση",
|
||||
"Amount": "Ποσότητα",
|
||||
"App": "Εφαρμογή",
|
||||
@@ -57,6 +65,7 @@
|
||||
"Comments_setting": "Εμφάνιση σχολίων",
|
||||
"Completed": "Ολοκληρωμένο",
|
||||
"Conversion": "Μετατροπή",
|
||||
"ConvertUsingAI": "",
|
||||
"Copy": "Αντιγραφή",
|
||||
"Copy Link": "Αντιγραφή συνδέσμου",
|
||||
"Copy Token": "Αντιγραφή token",
|
||||
@@ -66,6 +75,8 @@
|
||||
"Create": "Δημιουργία",
|
||||
"Create Food": "Δημιουργία φαγητού",
|
||||
"Create Recipe": "Δημιουργία συνταγής",
|
||||
"CreateFirstRecipe": "",
|
||||
"CreateInvitation": "",
|
||||
"Create_Meal_Plan_Entry": "Δημιουργία εγγραφής στο πρόγραμμα γευμάτων",
|
||||
"Create_New_Food": "Προσθήκη νέου φαγητού",
|
||||
"Create_New_Keyword": "Προσθήκη νέας λέξης-κλειδί",
|
||||
@@ -74,6 +85,7 @@
|
||||
"Create_New_Shopping_Category": "Προσθήκη νέας κατηγορίας αγορών",
|
||||
"Create_New_Unit": "Προσθήκη νέας μονάδας μέτρησης",
|
||||
"Created": "Δημιουργήθηκε",
|
||||
"Credits": "",
|
||||
"Current_Period": "Τρέχουσα περίοδος",
|
||||
"Custom Filter": "Προσαρμοσμένο φίλτρο",
|
||||
"CustomImageHelp": "Ανεβάστε μια εικόνα για να εμφανίζεται στην επισκόπηση χώρου",
|
||||
@@ -143,10 +155,14 @@
|
||||
"Food_Alias": "Ψευδώνυμο φαγητού",
|
||||
"Food_Replace": "Αντικατάσταση Φαγητού",
|
||||
"Foods": "Φαγητά",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "",
|
||||
"FulltextHelp": "",
|
||||
"Fuzzy": "",
|
||||
"FuzzySearchHelp": "",
|
||||
"Global": "",
|
||||
"GlobalHelp": "",
|
||||
"Group": "",
|
||||
"GroupBy": "Ομαδοποίηση κατά",
|
||||
"Hide_Food": "Απόκρυψη φαγητού",
|
||||
"Hide_Keyword": "Απόκρυψη λέξεων-κλειδί",
|
||||
@@ -165,6 +181,9 @@
|
||||
"Image": "Εικόνα",
|
||||
"Import": "Εισαγωγή",
|
||||
"Import Recipe": "Εισαγωγή συνταγής",
|
||||
"ImportFirstRecipe": "",
|
||||
"ImportMealPlans": "",
|
||||
"ImportShoppingList": "",
|
||||
"Import_Error": "Συνέβη ένα σφάλμα κατά την εισαγωγή. Για να το δείτε, εμφανίστε τις λεπτομέρειες στο κάτω μέρος της σελίδας.",
|
||||
"Import_Not_Yet_Supported": "Η εισαγωγή δεν υποστηρίζεται ακόμη",
|
||||
"Import_Result_Info": "Έγινε εισαγωγή {imported} από τις {total} συνταγές",
|
||||
@@ -195,13 +214,17 @@
|
||||
"Language": "Γλώσσα",
|
||||
"Last_name": "Επίθετο",
|
||||
"Learn_More": "Μάθετε περισσότερα",
|
||||
"LeaveSpace": "",
|
||||
"Link": "Σύνδεσμος",
|
||||
"Load_More": "Φόρτωση περισσότερων",
|
||||
"LogCredits": "",
|
||||
"LogCreditsHelp": "",
|
||||
"Log_Cooking": "Καταγραφή μαγειρέματος",
|
||||
"Log_Recipe_Cooking": "Καταγραφή εκτέλεσης συνταγής",
|
||||
"Logo": "Λογότυπο",
|
||||
"Make_Header": "Δημιουργία κεφαλίδας",
|
||||
"Make_Ingredient": "Δημιουργία υλικού",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Διαχείριση βιβλίων",
|
||||
"Manage_Emails": "Διαχείριση email",
|
||||
"Meal_Plan": "Πρόγραμμα γευμάτων",
|
||||
@@ -215,6 +238,8 @@
|
||||
"Message": "Μήνυμα",
|
||||
"MissingProperties": "",
|
||||
"Month": "Μήνας",
|
||||
"MonthlyCredits": "",
|
||||
"MonthlyCreditsUsed": "",
|
||||
"Move": "Μετακίνηση",
|
||||
"MoveCategory": "Μετακίνηση σε: ",
|
||||
"Move_Down": "Μετακίνηση κάτω",
|
||||
@@ -251,6 +276,8 @@
|
||||
"Note": "Σημείωση",
|
||||
"Number of Objects": "Αριθμός αντικειμένων",
|
||||
"Nutrition": "Διατροφική αξία",
|
||||
"NutritionsPerServing": "",
|
||||
"NutritionsPerServingHelp": "",
|
||||
"OfflineAlert": "Είστε εκτός σύνδεσης, η λίστα αγορών μπορεί να μην συγχρονιστεί.",
|
||||
"Ok": "ΟΚ",
|
||||
"OnHand": "Τώρα διαθέσιμα",
|
||||
@@ -327,6 +354,7 @@
|
||||
"Selected": "Επιλεγμένο",
|
||||
"Servings": "Μερίδες",
|
||||
"Settings": "Ρυθμίσεις",
|
||||
"SettingsOnlySuperuser": "",
|
||||
"Share": "Κοινοποίηση",
|
||||
"ShoppingBackgroundSyncWarning": "Κακό δίκτυο, αναμονή συγχρονισμού...",
|
||||
"Shopping_Categories": "Κατηγορίες αγορών",
|
||||
@@ -343,9 +371,14 @@
|
||||
"Show_as_header": "Εμφάνιση ως κεφαλίδα",
|
||||
"Single": "Ενικός",
|
||||
"Size": "Μέγεθος",
|
||||
"Skip": "",
|
||||
"Social_Authentication": "Ταυτοποίηση μέσω κοινωνικών δικτύων",
|
||||
"Sort_by_new": "Ταξινόμηση κατά νέο",
|
||||
"Space": "",
|
||||
"SpaceHelp": "",
|
||||
"SpaceMembersHelp": "",
|
||||
"SpaceName": "",
|
||||
"SpacePrivateObjectsHelp": "",
|
||||
"Space_Cosmetic_Settings": "Ορισμένες ρυθμίσεις εμφάνισης μπορούν να αλλάξουν από τους διαχειριστές του χώρου και θα παρακάμψουν τις ρυθμίσεις πελάτη για αυτόν τον χώρο.",
|
||||
"Split_All_Steps": "Διαχωρισμός όλων των γραμμών σε χωριστά βήματα.",
|
||||
"StartDate": "Ημερομηνία Έναρξης",
|
||||
@@ -412,6 +445,8 @@
|
||||
"Week": "Εβδομάδα",
|
||||
"Week_Numbers": "Αριθμοί εδομάδων",
|
||||
"Welcome": "Καλώς ήρθατε",
|
||||
"WelcomeSettingsHelp": "",
|
||||
"WelcometoTandoor": "",
|
||||
"Year": "Έτος",
|
||||
"Yes": "",
|
||||
"add_keyword": "Προσθήκη λέξης-κλειδί",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"AI": "AI",
|
||||
"AIImportSubtitle": "Use AI to import images of recipes.",
|
||||
"AISettingsHostedHelp": "You can enable AI features or change available credits by managing your subscription.",
|
||||
"API": "API",
|
||||
"APIKey": "API key",
|
||||
"API_Browser": "API Browser",
|
||||
"API_Documentation": "API Docs",
|
||||
"AccessTokenHelp": "Access keys for the REST API.",
|
||||
"Access_Token": "Access Token",
|
||||
"Account": "Account",
|
||||
"Actions": "Actions",
|
||||
"Active": "Active",
|
||||
"Activity": "Activity",
|
||||
"Add": "Add",
|
||||
"AddAll": "Add all",
|
||||
@@ -26,6 +29,12 @@
|
||||
"Added_on": "Added On",
|
||||
"Admin": "Admin",
|
||||
"Advanced": "Advanced",
|
||||
"AiCreditsBalance": "Credit Balance",
|
||||
"AiLog": "AI Log",
|
||||
"AiLogHelp": "Overview of your spaces AI requests. ",
|
||||
"AiModelHelp": "The list contains model that are offically tested and supported. You can add additional models if you want.",
|
||||
"AiProvider": "AI Provider",
|
||||
"AiProviderHelp": "You can configure multiple AI providers according to your preferences. They can even be configured to work across multiple spaces.",
|
||||
"Alignment": "Alignment",
|
||||
"AllRecipes": "All Recipes",
|
||||
"Amount": "Amount",
|
||||
@@ -87,6 +96,7 @@
|
||||
"Continue": "Continue",
|
||||
"Conversion": "Conversion",
|
||||
"ConversionsHelp": "With conversions you can calculate the amount of a food in different units. Currently this is only used for property calculation, later it might also be used in other parts of tandoor. ",
|
||||
"ConvertUsingAI": "Convert using AI",
|
||||
"CookLog": "Cook Log",
|
||||
"CookLogHelp": "Entries in the cook log for recipes. ",
|
||||
"Cooked": "Cooked",
|
||||
@@ -100,6 +110,8 @@
|
||||
"Create": "Create",
|
||||
"Create Food": "Create Food",
|
||||
"Create Recipe": "Create Recipe",
|
||||
"CreateFirstRecipe": "Create your first recipe using the recipe editor.",
|
||||
"CreateInvitation": "Create invitation",
|
||||
"Create_Meal_Plan_Entry": "Create meal plan entry",
|
||||
"Create_New_Food": "Add New Food",
|
||||
"Create_New_Keyword": "Add New Keyword",
|
||||
@@ -109,6 +121,7 @@
|
||||
"Create_New_Unit": "Add New Unit",
|
||||
"Created": "Created",
|
||||
"CreatedBy": "Created by",
|
||||
"Credits": "Credits",
|
||||
"Ctrl+K": "Ctrl+K",
|
||||
"Current_Period": "Current Period",
|
||||
"Custom Filter": "Custom Filter",
|
||||
@@ -202,11 +215,15 @@
|
||||
"Food_Replace": "Food Replace",
|
||||
"Foods": "Foods",
|
||||
"Friday": "Friday",
|
||||
"FromBalance": "From Balance",
|
||||
"Fulltext": "Fulltext",
|
||||
"FulltextHelp": "Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields.",
|
||||
"Fuzzy": "Fuzzy",
|
||||
"FuzzySearchHelp": "Use fuzzy search to find entries even when there are differences in how the word is written.",
|
||||
"GettingStarted": "Getting Started",
|
||||
"Global": "Global",
|
||||
"GlobalHelp": "Global AI Providers can be used by users of all spaces. They can only be created and edited by superusers. ",
|
||||
"Group": "Group",
|
||||
"GroupBy": "Group By",
|
||||
"HeaderWarning": "Warning: Changing to a Heading deletes the Amount/Unit/Food",
|
||||
"Headline": "Headline",
|
||||
@@ -232,7 +249,10 @@
|
||||
"Import": "Import",
|
||||
"Import Recipe": "Import Recipe",
|
||||
"ImportAll": "Import all",
|
||||
"ImportFirstRecipe": "Import your first recipe from one of thousands of websites or use one of the other importers to import your existing collection, documents or URL lists.",
|
||||
"ImportIntoTandoor": "Import into Tandoor",
|
||||
"ImportMealPlans": "Import mealplans",
|
||||
"ImportShoppingList": "Import shoppinglists",
|
||||
"Import_Error": "An Error occurred during your import. Please expand the Details at the bottom of the page to view it.",
|
||||
"Import_Not_Yet_Supported": "Import not yet supported",
|
||||
"Import_Result_Info": "{imported} of {total} recipes were imported",
|
||||
@@ -271,9 +291,12 @@
|
||||
"Last": "Last",
|
||||
"Last_name": "Last Name",
|
||||
"Learn_More": "Learn More",
|
||||
"LeaveSpace": "Leave Space",
|
||||
"Link": "Link",
|
||||
"Load": "Load",
|
||||
"Load_More": "Load More",
|
||||
"LogCredits": "Log Credits.",
|
||||
"LogCreditsHelp": "Log credit cost of AI requests. Without this users can perform as many AI requests as they want. ",
|
||||
"Log_Cooking": "Log Cooking",
|
||||
"Log_Recipe_Cooking": "Log Recipe Cooking",
|
||||
"Logo": "Logo",
|
||||
@@ -299,9 +322,12 @@
|
||||
"Miscellaneous": "Miscellaneous",
|
||||
"MissingConversion": "Missing Conversion",
|
||||
"MissingProperties": "Missing Properties",
|
||||
"Model": "Model",
|
||||
"ModelSelectResultsHelp": "Search for more results",
|
||||
"Monday": "Monday",
|
||||
"Month": "Month",
|
||||
"MonthlyCredits": "Monthly Credits",
|
||||
"MonthlyCreditsUsed": "Monthly credits used",
|
||||
"More": "More",
|
||||
"Move": "Move",
|
||||
"MoveCategory": "Move To: ",
|
||||
@@ -343,6 +369,8 @@
|
||||
"Note": "Note",
|
||||
"Number of Objects": "Number of Objects",
|
||||
"Nutrition": "Nutrition",
|
||||
"NutritionsPerServing": "Nutritions per Serving",
|
||||
"NutritionsPerServingHelp": "Some applications do not specify if nutritions are per recipe or per serving. By default Tandoor treats them as per recipe. Check this box to treat them as per serving. ",
|
||||
"OfflineAlert": "You are offline, shopping list may not syncronize.",
|
||||
"Ok": "Ok",
|
||||
"OnHand": "Currently On Hand",
|
||||
@@ -455,6 +483,7 @@
|
||||
"Servings": "Servings",
|
||||
"ServingsText": "Servings Text",
|
||||
"Settings": "Settings",
|
||||
"SettingsOnlySuperuser": "Some Settings can only be changed by the Server Administrator.",
|
||||
"Share": "Share",
|
||||
"ShopLater": "Shop later",
|
||||
"ShopNow": "Shop now",
|
||||
@@ -478,17 +507,21 @@
|
||||
"Show_as_header": "Show as header",
|
||||
"Single": "Single",
|
||||
"Size": "Size",
|
||||
"Skip": "Skip",
|
||||
"Social_Authentication": "Social Authentication",
|
||||
"Sort_by_new": "Sort by new",
|
||||
"Source": "Source",
|
||||
"SourceImportHelp": "Import JSON in schema.org/recipe format or html pages with json+ld recipe or microdata.",
|
||||
"SourceImportSubtitle": "Import JSON or HTML manually.",
|
||||
"Space": "Space",
|
||||
"SpaceHelp": "All your data is part of your space and can only be acccessed by space members. ",
|
||||
"SpaceLimitExceeded": "Your space has surpassed one of its limits, some functions might be restricted.",
|
||||
"SpaceLimitReached": "This Space has reached a limit. No more objects of this type can be created.",
|
||||
"SpaceMemberHelp": "Add users to your space by creating an Invite Link and sending it to the person you want to add.",
|
||||
"SpaceMembers": "Space Members",
|
||||
"SpaceMembersHelp": "Users and their permissions in a space. ",
|
||||
"SpaceMembersHelp": "Users and their permissions in a space. Add additional users using invite links.",
|
||||
"SpaceName": "Space Name",
|
||||
"SpacePrivateObjectsHelp": " Some things are private by default an can be shared with members of your space.",
|
||||
"SpaceSettings": "Space Settings",
|
||||
"Space_Cosmetic_Settings": "Some cosmetic settings can be changed by space administrators and will override client settings for that space.",
|
||||
"Split": "Split",
|
||||
@@ -509,10 +542,12 @@
|
||||
"Storage": "External Storage",
|
||||
"StorageHelp": "External storage locations where recipe files (image/pdf) can be stored and synced with Tandoor.",
|
||||
"StoragePasswordTokenHelp": "The stored password/token will never be displayed. It is only changed if something new is entered into the field. ",
|
||||
"Structured": "Structured",
|
||||
"SubstituteOnHand": "You have a substitute on hand.",
|
||||
"Substitutes": "Substitutes",
|
||||
"Success": "Success",
|
||||
"SuccessClipboard": "Shopping list copied to clipboard",
|
||||
"Summary": "Summary",
|
||||
"Sunday": "Sunday",
|
||||
"Supermarket": "Supermarket",
|
||||
"SupermarketCategoriesOnly": "Supermarket Categories Only",
|
||||
@@ -598,6 +633,8 @@
|
||||
"Week": "Week",
|
||||
"Week_Numbers": "Week numbers",
|
||||
"Welcome": "Welcome",
|
||||
"WelcomeSettingsHelp": "Please choose the basic settings for your Tandoor space. You can change all of these later trough the settings.",
|
||||
"WelcometoTandoor": "Welcome to Tandoor",
|
||||
"WorkingTime": "Working time",
|
||||
"Year": "Year",
|
||||
"Yes": "Yes",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"AI": "IA",
|
||||
"AIImportSubtitle": "Usar IA para importar imágenes de recetas.",
|
||||
"AISettingsHostedHelp": "",
|
||||
"API": "API",
|
||||
"API_Browser": "",
|
||||
"API_Documentation": "",
|
||||
@@ -8,6 +9,7 @@
|
||||
"Access_Token": "Token de acceso",
|
||||
"Account": "Cuenta",
|
||||
"Actions": "Acciones",
|
||||
"Active": "",
|
||||
"Activity": "Actividad",
|
||||
"Add": "Añadir",
|
||||
"AddAll": "Agregar todo",
|
||||
@@ -26,6 +28,12 @@
|
||||
"Added_on": "Añadido el",
|
||||
"Admin": "Administrador",
|
||||
"Advanced": "Avanzado",
|
||||
"AiCreditsBalance": "",
|
||||
"AiLog": "",
|
||||
"AiLogHelp": "",
|
||||
"AiModelHelp": "",
|
||||
"AiProvider": "",
|
||||
"AiProviderHelp": "",
|
||||
"Alignment": "Alineación",
|
||||
"AllRecipes": "Todas las recetas",
|
||||
"Amount": "Cantidad",
|
||||
@@ -85,6 +93,7 @@
|
||||
"Continue": "Continuar",
|
||||
"Conversion": "Conversión",
|
||||
"ConversionsHelp": "Con las conversiones puedes calcular la cantidad de un alimento en diferentes unidades. Actualmente esto solo se usa para el cálculo de propiedades, en un futuro podría ser usado en otras partes de Tandoor. ",
|
||||
"ConvertUsingAI": "",
|
||||
"CookLog": "Historial de cocina",
|
||||
"CookLogHelp": "Entradas en el historial de cocina para recetas. ",
|
||||
"Cooked": "Cocinado",
|
||||
@@ -98,6 +107,8 @@
|
||||
"Create": "Crear",
|
||||
"Create Food": "Crear alimento",
|
||||
"Create Recipe": "Crear receta",
|
||||
"CreateFirstRecipe": "",
|
||||
"CreateInvitation": "",
|
||||
"Create_Meal_Plan_Entry": "Crear entrada de plan de comidas",
|
||||
"Create_New_Food": "Añadir nuevo alimento",
|
||||
"Create_New_Keyword": "Añadir nueva palabra clave",
|
||||
@@ -107,6 +118,7 @@
|
||||
"Create_New_Unit": "Añadir nueva unidad",
|
||||
"Created": "Creada",
|
||||
"CreatedBy": "Creado por",
|
||||
"Credits": "",
|
||||
"Ctrl+K": "Ctrl+K",
|
||||
"Current_Period": "Periodo actual",
|
||||
"Custom Filter": "Filtro personalizado",
|
||||
@@ -200,7 +212,11 @@
|
||||
"Food_Replace": "Sustituir Alimento",
|
||||
"Foods": "Alimentos",
|
||||
"Friday": "Viernes",
|
||||
"FromBalance": "",
|
||||
"GettingStarted": "Primeros pasos",
|
||||
"Global": "",
|
||||
"GlobalHelp": "",
|
||||
"Group": "",
|
||||
"GroupBy": "Agrupar por",
|
||||
"HeaderWarning": "Advertencia: Cambiar a un encabezado eliminará la cantidad/unidad/alimento",
|
||||
"Headline": "Encabezado",
|
||||
@@ -224,7 +240,10 @@
|
||||
"Import": "Importar",
|
||||
"Import Recipe": "Importar Receta",
|
||||
"ImportAll": "Importar todo",
|
||||
"ImportFirstRecipe": "",
|
||||
"ImportIntoTandoor": "Importar a Tandoor",
|
||||
"ImportMealPlans": "",
|
||||
"ImportShoppingList": "",
|
||||
"Import_Error": "Ocurrió un Error ocurrió durante la importación. Por favor, expanda los Detalles al final de la página para verlo.",
|
||||
"Import_Not_Yet_Supported": "Importación no soportada todavía",
|
||||
"Import_Result_Info": "{imported} de {total} recetas fueron importadas",
|
||||
@@ -263,9 +282,12 @@
|
||||
"Last": "Último",
|
||||
"Last_name": "Apellidos",
|
||||
"Learn_More": "Saber Más",
|
||||
"LeaveSpace": "",
|
||||
"Link": "Enlace",
|
||||
"Load": "Cargar",
|
||||
"Load_More": "Cargar más",
|
||||
"LogCredits": "",
|
||||
"LogCreditsHelp": "",
|
||||
"Log_Cooking": "Registrar cocinada",
|
||||
"Log_Recipe_Cooking": "Registro de recetas",
|
||||
"Logo": "Logotipo",
|
||||
@@ -294,6 +316,8 @@
|
||||
"ModelSelectResultsHelp": "Buscar más resultados",
|
||||
"Monday": "Lunes",
|
||||
"Month": "Mes",
|
||||
"MonthlyCredits": "",
|
||||
"MonthlyCreditsUsed": "",
|
||||
"More": "Más",
|
||||
"Move": "Mover",
|
||||
"MoveCategory": "Mover a: ",
|
||||
@@ -335,6 +359,8 @@
|
||||
"Note": "Nota",
|
||||
"Number of Objects": "Número de Objetos",
|
||||
"Nutrition": "Nutrición",
|
||||
"NutritionsPerServing": "",
|
||||
"NutritionsPerServingHelp": "",
|
||||
"OfflineAlert": "Estas desconectado, la lista de la compra puede no sincronizarse.",
|
||||
"Ok": "Ok",
|
||||
"OnHand": "Actualmente en Posesión",
|
||||
@@ -441,6 +467,7 @@
|
||||
"Servings": "Raciones",
|
||||
"ServingsText": "Texto de la porción",
|
||||
"Settings": "Opciones",
|
||||
"SettingsOnlySuperuser": "",
|
||||
"Share": "Compartir",
|
||||
"ShopLater": "Comprar después",
|
||||
"ShopNow": "Comprar ahora",
|
||||
@@ -463,16 +490,20 @@
|
||||
"Show_as_header": "Mostrar como encabezado",
|
||||
"Single": "Simple",
|
||||
"Size": "Tamaño",
|
||||
"Skip": "",
|
||||
"Social_Authentication": "Autenticación Social",
|
||||
"Sort_by_new": "Ordenar por novedades",
|
||||
"SourceImportHelp": "Importar JSON en formato schema.org/recipe o páginas HTML con recetas en formato JSON+LD o microdatos.",
|
||||
"SourceImportSubtitle": "Importar JSON o HTML manualmente.",
|
||||
"Space": "",
|
||||
"SpaceHelp": "",
|
||||
"SpaceLimitExceeded": "Tu espacio ha sobrepasado uno de sus límites, algunas funciones podrían estar restringidas.",
|
||||
"SpaceLimitReached": "Este espacio ha alcanzado un límite. No se pueden crear más objetos de este tipo.",
|
||||
"SpaceMemberHelp": "Agrega usuarios a tu espacio creando un enlace de invitación y enviándolo a la persona que quieras agregar.",
|
||||
"SpaceMembers": "Miembros del espacio",
|
||||
"SpaceMembersHelp": "Usuarios y sus permisos en un espacio. ",
|
||||
"SpaceMembersHelp": "",
|
||||
"SpaceName": "",
|
||||
"SpacePrivateObjectsHelp": "",
|
||||
"SpaceSettings": "Ajustes del espacio",
|
||||
"Space_Cosmetic_Settings": "Algunos ajustes de apariencia pueden ser cambiados por los administradores del espacio y anularán los ajustes del cliente para ese espacio.",
|
||||
"Split": "Dividir",
|
||||
@@ -578,6 +609,8 @@
|
||||
"Week": "Semana",
|
||||
"Week_Numbers": "numero de semana",
|
||||
"Welcome": "Bienvenido/a",
|
||||
"WelcomeSettingsHelp": "",
|
||||
"WelcometoTandoor": "",
|
||||
"WorkingTime": "Tiempo de trabajo",
|
||||
"Year": "Año",
|
||||
"Yes": "",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"AISettingsHostedHelp": "",
|
||||
"API": "API",
|
||||
"API_Browser": "",
|
||||
"API_Documentation": "",
|
||||
"Account": "Tili",
|
||||
"Active": "",
|
||||
"Add": "Lisää",
|
||||
"AddChild": "",
|
||||
"AddFoodToShopping": "Lisää {food} ostoslistaan",
|
||||
@@ -17,6 +19,12 @@
|
||||
"Added_on": "Lisätty",
|
||||
"Advanced": "Edistynyt",
|
||||
"Advanced Search Settings": "Tarkennetun Haun Asetukset",
|
||||
"AiCreditsBalance": "",
|
||||
"AiLog": "",
|
||||
"AiLogHelp": "",
|
||||
"AiModelHelp": "",
|
||||
"AiProvider": "",
|
||||
"AiProviderHelp": "",
|
||||
"Alignment": "Tasaus",
|
||||
"Amount": "Määrä",
|
||||
"App": "Applikaatio",
|
||||
@@ -55,6 +63,7 @@
|
||||
"Comments_setting": "Näytä Kommentit",
|
||||
"Completed": "Valmis",
|
||||
"Conversion": "Muuntaminen",
|
||||
"ConvertUsingAI": "",
|
||||
"Copy": "Kopioi",
|
||||
"Copy Link": "Kopioi Linkki",
|
||||
"Copy Token": "Kopioi Token",
|
||||
@@ -63,6 +72,8 @@
|
||||
"CountMore": "...+{count} enemmän",
|
||||
"Create": "Luo",
|
||||
"Create Food": "Luo Ruoka",
|
||||
"CreateFirstRecipe": "",
|
||||
"CreateInvitation": "",
|
||||
"Create_Meal_Plan_Entry": "Luo ateriasuunnitelma merkintä",
|
||||
"Create_New_Food": "Lisää Uusi Ruoka",
|
||||
"Create_New_Keyword": "Lisää Uusi Avainsana",
|
||||
@@ -71,6 +82,7 @@
|
||||
"Create_New_Shopping_Category": "Lisää uusi ostoskategoria",
|
||||
"Create_New_Unit": "Lisää Uusi Yksikkö",
|
||||
"Created": "Luotu",
|
||||
"Credits": "",
|
||||
"Current_Period": "Nykyinen Jakso",
|
||||
"Custom Filter": "Mukautettu Suodatin",
|
||||
"CustomImageHelp": "Lataa kuva näytettäväksi tilan yleiskatsauksessa.",
|
||||
@@ -140,10 +152,14 @@
|
||||
"Food_Alias": "Ruoan nimimerkki",
|
||||
"Food_Replace": "Korvaa Ruoka",
|
||||
"Foods": "Ruuat",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "",
|
||||
"FulltextHelp": "",
|
||||
"Fuzzy": "",
|
||||
"FuzzySearchHelp": "",
|
||||
"Global": "",
|
||||
"GlobalHelp": "",
|
||||
"Group": "",
|
||||
"GroupBy": "Ryhmittely peruste",
|
||||
"Hide_Food": "Piilota Ruoka",
|
||||
"Hide_Keyword": "Piilota avainsana",
|
||||
@@ -162,6 +178,9 @@
|
||||
"Image": "Kuva",
|
||||
"Import": "Tuo",
|
||||
"Import Recipe": "Tuo Resepti",
|
||||
"ImportFirstRecipe": "",
|
||||
"ImportMealPlans": "",
|
||||
"ImportShoppingList": "",
|
||||
"Import_Not_Yet_Supported": "Tuontia ei vielä tueta",
|
||||
"Import_Supported": "Tuonti tuettu",
|
||||
"Import_finished": "Tuonti valmistui",
|
||||
@@ -189,13 +208,17 @@
|
||||
"Language": "Kieli",
|
||||
"Last_name": "Sukunimi",
|
||||
"Learn_More": "Lisätietoja",
|
||||
"LeaveSpace": "",
|
||||
"Link": "Linkki",
|
||||
"Load_More": "Lataa Lisää",
|
||||
"LogCredits": "",
|
||||
"LogCreditsHelp": "",
|
||||
"Log_Cooking": "Kirjaa kokkaus",
|
||||
"Log_Recipe_Cooking": "Kirjaa Reseptin valmistus",
|
||||
"Logo": "Logo",
|
||||
"Make_Header": "Valmista Otsikko",
|
||||
"Make_Ingredient": "Valmista Ainesosa",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Hallinnoi kirjoja",
|
||||
"Manage_Emails": "Hallinnoi sähköposteja",
|
||||
"Meal_Plan": "Ateriasuunnitelma",
|
||||
@@ -209,6 +232,8 @@
|
||||
"Message": "Viesti",
|
||||
"MissingProperties": "",
|
||||
"Month": "Kuukausi",
|
||||
"MonthlyCredits": "",
|
||||
"MonthlyCreditsUsed": "",
|
||||
"Move": "Siirry",
|
||||
"MoveCategory": "Siirrä paikkaan: ",
|
||||
"Move_Down": "Siirry alas",
|
||||
@@ -240,6 +265,8 @@
|
||||
"Note": "Lisätiedot",
|
||||
"Number of Objects": "Objektien määrä",
|
||||
"Nutrition": "Ravitsemus",
|
||||
"NutritionsPerServing": "",
|
||||
"NutritionsPerServingHelp": "",
|
||||
"OfflineAlert": "Olet offline-tilassa, ostoslista ei välttämättä synkronoidu.",
|
||||
"Ok": "Ok",
|
||||
"OnHand": "Tällä hetkellä saatavilla",
|
||||
@@ -316,6 +343,7 @@
|
||||
"Selected": "Valittu",
|
||||
"Servings": "Annokset",
|
||||
"Settings": "Asetukset",
|
||||
"SettingsOnlySuperuser": "",
|
||||
"Share": "Jaa",
|
||||
"ShoppingBackgroundSyncWarning": "Huono verkkoyhteys, odotetaan synkronointia ...",
|
||||
"Shopping_Categories": "Ostoskategoriat",
|
||||
@@ -331,9 +359,14 @@
|
||||
"Show_as_header": "Näytä otsikkona",
|
||||
"Single": "Yksittäinen",
|
||||
"Size": "Koko",
|
||||
"Skip": "",
|
||||
"Social_Authentication": "Sosiaalinen Todennus",
|
||||
"Sort_by_new": "Lajittele uusien mukaan",
|
||||
"Space": "",
|
||||
"SpaceHelp": "",
|
||||
"SpaceMembersHelp": "",
|
||||
"SpaceName": "",
|
||||
"SpacePrivateObjectsHelp": "",
|
||||
"Split_All_Steps": "Jaa kaikki rivit erillisiin vaiheisiin.",
|
||||
"StartDate": "Aloituspäivä",
|
||||
"Starting_Day": "Viikon aloituspäivä",
|
||||
@@ -392,6 +425,8 @@
|
||||
"Week": "Viikko",
|
||||
"Week_Numbers": "Viikkonumerot",
|
||||
"Welcome": "Tervetuloa",
|
||||
"WelcomeSettingsHelp": "",
|
||||
"WelcometoTandoor": "",
|
||||
"Year": "Vuosi",
|
||||
"Yes": "",
|
||||
"add_keyword": "Lisää Avainsana",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"AI": "IA",
|
||||
"AIImportSubtitle": "Utiliser l'IA pour importer des images de recettes.",
|
||||
"AISettingsHostedHelp": "",
|
||||
"API": "API",
|
||||
"API_Browser": "",
|
||||
"API_Documentation": "",
|
||||
@@ -8,6 +9,7 @@
|
||||
"Access_Token": "Jeton d'accès",
|
||||
"Account": "Compte",
|
||||
"Actions": "Actions",
|
||||
"Active": "",
|
||||
"Activity": "Activité",
|
||||
"Add": "Ajouter",
|
||||
"AddAll": "Tout ajouter",
|
||||
@@ -27,6 +29,12 @@
|
||||
"Admin": "Admin",
|
||||
"Advanced": "Avancé",
|
||||
"Advanced Search Settings": "Paramètres de recherche avancée",
|
||||
"AiCreditsBalance": "",
|
||||
"AiLog": "",
|
||||
"AiLogHelp": "",
|
||||
"AiModelHelp": "",
|
||||
"AiProvider": "",
|
||||
"AiProviderHelp": "",
|
||||
"Alignment": "Alignement",
|
||||
"AllRecipes": "Toutes les recettes",
|
||||
"Amount": "Quantité",
|
||||
@@ -88,6 +96,7 @@
|
||||
"Continue": "Continuer",
|
||||
"Conversion": "Conversion",
|
||||
"ConversionsHelp": "Avec les conversions, vous pouvez calculer une quantité dans différentes unités. Actuellement, c'est utilisé uniquement pour le calcul des propriétés, mais ça pourrait être utilisé dans d'autres parties de Tandoor dans le futur. ",
|
||||
"ConvertUsingAI": "",
|
||||
"CookLog": "Journal de cuisine",
|
||||
"CookLogHelp": "Entrées dans le journal de cuisine pour les recettes. ",
|
||||
"Cooked": "Cuit",
|
||||
@@ -101,6 +110,8 @@
|
||||
"Create": "Créer",
|
||||
"Create Food": "Créer un aliment",
|
||||
"Create Recipe": "Créer une recette",
|
||||
"CreateFirstRecipe": "",
|
||||
"CreateInvitation": "",
|
||||
"Create_Meal_Plan_Entry": "Créer une entrée de menu",
|
||||
"Create_New_Food": "Ajouter un nouvel aliment",
|
||||
"Create_New_Keyword": "Ajouter un nouveau mot-clé",
|
||||
@@ -110,6 +121,7 @@
|
||||
"Create_New_Unit": "Ajouter une nouvelle unité",
|
||||
"Created": "Créé",
|
||||
"CreatedBy": "Créé par",
|
||||
"Credits": "",
|
||||
"Ctrl+K": "Ctrl+K",
|
||||
"Current_Period": "Période actuelle",
|
||||
"Custom Filter": "Filtre personnalisé",
|
||||
@@ -203,11 +215,15 @@
|
||||
"Food_Replace": "Remplacer l'aliment",
|
||||
"Foods": "Aliments",
|
||||
"Friday": "Vendredi",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "Texte intégral",
|
||||
"FulltextHelp": "Champs de recherche en texte intégral. Remarque : les méthodes de recherche \"web\", \"phrase\" et \"raw\" ne fonctionnent qu'avec des champs en texte intégral.",
|
||||
"Fuzzy": "Approximatif",
|
||||
"FuzzySearchHelp": "Utilisez la recherche approximative pour trouver des entrées même lorsqu'il existe des différences dans la façon dont le mot est écrit.",
|
||||
"GettingStarted": "Commencer",
|
||||
"Global": "",
|
||||
"GlobalHelp": "",
|
||||
"Group": "",
|
||||
"GroupBy": "Grouper par",
|
||||
"HeaderWarning": "Attention : Changer pour un En-tête supprimera la quantité / l'unité / l'aliment",
|
||||
"Headline": "En-tête",
|
||||
@@ -233,7 +249,10 @@
|
||||
"Import": "Importer",
|
||||
"Import Recipe": "Importer une recette",
|
||||
"ImportAll": "Tout importer",
|
||||
"ImportFirstRecipe": "",
|
||||
"ImportIntoTandoor": "Importer dans Tandoor",
|
||||
"ImportMealPlans": "",
|
||||
"ImportShoppingList": "",
|
||||
"Import_Error": "Une erreur est survenue pendant votre importation. Veuillez développer les détails au bas de la page pour la consulter.",
|
||||
"Import_Not_Yet_Supported": "Importation pas encore prise en charge",
|
||||
"Import_Result_Info": "{imported} sur {total} recettes ont été importées",
|
||||
@@ -272,9 +291,12 @@
|
||||
"Last": "Dernier",
|
||||
"Last_name": "Nom",
|
||||
"Learn_More": "Apprenez-en plus",
|
||||
"LeaveSpace": "",
|
||||
"Link": "Lien",
|
||||
"Load": "Chargement",
|
||||
"Load_More": "Charger plus",
|
||||
"LogCredits": "",
|
||||
"LogCreditsHelp": "",
|
||||
"Log_Cooking": "Marquer comme cuisiné",
|
||||
"Log_Recipe_Cooking": "Marquer la recette comme cuisinée",
|
||||
"Logo": "Logo",
|
||||
@@ -301,6 +323,8 @@
|
||||
"ModelSelectResultsHelp": "Chercher plus de résultats",
|
||||
"Monday": "Lundi",
|
||||
"Month": "Mois",
|
||||
"MonthlyCredits": "",
|
||||
"MonthlyCreditsUsed": "",
|
||||
"More": "Plus",
|
||||
"Move": "Déplacer",
|
||||
"MoveCategory": "Déplacer vers : ",
|
||||
@@ -342,6 +366,8 @@
|
||||
"Note": "Notes",
|
||||
"Number of Objects": "Nombre d'objets",
|
||||
"Nutrition": "Valeurs nutritionnelles",
|
||||
"NutritionsPerServing": "",
|
||||
"NutritionsPerServingHelp": "",
|
||||
"OfflineAlert": "Vous êtes déconnecté, votre liste de courses peut ne pas être synchronisée.",
|
||||
"Ok": "D'accord",
|
||||
"OnHand": "Disponible actuellement",
|
||||
@@ -454,6 +480,7 @@
|
||||
"Servings": "Portions",
|
||||
"ServingsText": "Texte des portions",
|
||||
"Settings": "Paramètres",
|
||||
"SettingsOnlySuperuser": "",
|
||||
"Share": "Partager",
|
||||
"ShopLater": "Acheter plus tard",
|
||||
"ShopNow": "Acheter maintenant",
|
||||
@@ -477,17 +504,21 @@
|
||||
"Show_as_header": "Montrer comme en-tête",
|
||||
"Single": "Unique",
|
||||
"Size": "Taille",
|
||||
"Skip": "",
|
||||
"Social_Authentication": "Authentification Sociale",
|
||||
"Sort_by_new": "Trier par nouveautés",
|
||||
"Source": "Source",
|
||||
"SourceImportHelp": "Importez du JSON au format schema.org/recipe ou des pages HTML avec une recette json+ld ou des microdonnées.",
|
||||
"SourceImportSubtitle": "Importez en JSON ou HTML manuellement.",
|
||||
"Space": "",
|
||||
"SpaceHelp": "",
|
||||
"SpaceLimitExceeded": "Votre groupe a dépassé une de ses limites, certaines fonctions pourraient être restreintes.",
|
||||
"SpaceLimitReached": "Ce groupe a atteint sa limite. Aucun nouvel objet de ce type ne peut être créé.",
|
||||
"SpaceMemberHelp": "Ajoutez des utilisateurs à votre espace en créant un lien d'invitation et en l'envoyant à la personne que vous souhaitez ajouter.",
|
||||
"SpaceMembers": "Membres du groupe",
|
||||
"SpaceMembersHelp": "Utilisateurs et permissions dans un groupe. ",
|
||||
"SpaceMembersHelp": "",
|
||||
"SpaceName": "",
|
||||
"SpacePrivateObjectsHelp": "",
|
||||
"SpaceSettings": "Paramètres du groupe",
|
||||
"Space_Cosmetic_Settings": "Certains paramètres cosmétiques peuvent être modifiés par un administrateur de l'espace et seront prioritaires sur les paramètres des utilisateurs pour cet espace.",
|
||||
"Split": "Diviser",
|
||||
@@ -597,6 +628,8 @@
|
||||
"Week": "Semaine",
|
||||
"Week_Numbers": "Numéro de semaine",
|
||||
"Welcome": "Bienvenue",
|
||||
"WelcomeSettingsHelp": "",
|
||||
"WelcometoTandoor": "",
|
||||
"WorkingTime": "Temps de préparation",
|
||||
"Year": "Année",
|
||||
"Yes": "",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"AISettingsHostedHelp": "",
|
||||
"API": "API",
|
||||
"API_Browser": "",
|
||||
"API_Documentation": "",
|
||||
"Account": "חשבון",
|
||||
"Active": "",
|
||||
"Add": "הוספה",
|
||||
"AddChild": "",
|
||||
"AddFoodToShopping": "הוסף {מזון} לרשימת הקניות",
|
||||
@@ -16,6 +18,12 @@
|
||||
"Added_by": "נוסף ע\"י",
|
||||
"Added_on": "נוסף ב",
|
||||
"Advanced": "מתקדם",
|
||||
"AiCreditsBalance": "",
|
||||
"AiLog": "",
|
||||
"AiLogHelp": "",
|
||||
"AiModelHelp": "",
|
||||
"AiProvider": "",
|
||||
"AiProviderHelp": "",
|
||||
"Alignment": "יישור",
|
||||
"Amount": "כמות",
|
||||
"App": "אפליקציה",
|
||||
@@ -57,6 +65,7 @@
|
||||
"Comments_setting": "הצג תגובות",
|
||||
"Completed": "הושלם",
|
||||
"Conversion": "עברית",
|
||||
"ConvertUsingAI": "",
|
||||
"Copy": "העתקה",
|
||||
"Copy Link": "העתק קישור",
|
||||
"Copy Token": "העתק טוקן",
|
||||
@@ -66,6 +75,8 @@
|
||||
"Create": "יצירה",
|
||||
"Create Food": "צור מאכל",
|
||||
"Create Recipe": "צור מתכון",
|
||||
"CreateFirstRecipe": "",
|
||||
"CreateInvitation": "",
|
||||
"Create_Meal_Plan_Entry": "צור רשימת תכנון אוכל",
|
||||
"Create_New_Food": "הוסף אוכל חדש",
|
||||
"Create_New_Keyword": "הוסף מילת מפתח",
|
||||
@@ -74,6 +85,7 @@
|
||||
"Create_New_Shopping_Category": "הוסף קטגוריות קניות חדשה",
|
||||
"Create_New_Unit": "הוסף יחידה",
|
||||
"Created": "נוצר",
|
||||
"Credits": "",
|
||||
"Current_Period": "תקופה נוכחית",
|
||||
"Custom Filter": "פילטר מותאם",
|
||||
"CustomImageHelp": "העלאת תמונה שתראה באזור הסקירה.",
|
||||
@@ -143,10 +155,14 @@
|
||||
"Food_Alias": "שם כינוי לאוכל",
|
||||
"Food_Replace": "החלף אוכל",
|
||||
"Foods": "מאכלים",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "",
|
||||
"FulltextHelp": "",
|
||||
"Fuzzy": "",
|
||||
"FuzzySearchHelp": "",
|
||||
"Global": "",
|
||||
"GlobalHelp": "",
|
||||
"Group": "",
|
||||
"GroupBy": "אסוף לפי",
|
||||
"Hide_Food": "הסתר אוכל",
|
||||
"Hide_Keyword": "הסתר מילות מפתח",
|
||||
@@ -165,6 +181,9 @@
|
||||
"Image": "תמונה",
|
||||
"Import": "ייבוא",
|
||||
"Import Recipe": "ייבא מתכון",
|
||||
"ImportFirstRecipe": "",
|
||||
"ImportMealPlans": "",
|
||||
"ImportShoppingList": "",
|
||||
"Import_Error": "שגיאה בעת ייבוא. הרחב את הפירוט בסוף עמוד זה לראות מידע נוסף.",
|
||||
"Import_Not_Yet_Supported": "ייבוא לא נתמך עדיין",
|
||||
"Import_Result_Info": "{imported} מתוך {total} מתכונים יובאו",
|
||||
@@ -195,13 +214,17 @@
|
||||
"Language": "שפה",
|
||||
"Last_name": "שם משפחה",
|
||||
"Learn_More": "למד עוד",
|
||||
"LeaveSpace": "",
|
||||
"Link": "קישור",
|
||||
"Load_More": "טען עוד",
|
||||
"LogCredits": "",
|
||||
"LogCreditsHelp": "",
|
||||
"Log_Cooking": "רשום הכנת מתכון",
|
||||
"Log_Recipe_Cooking": "רשום בישול מתכון",
|
||||
"Logo": "לוגו",
|
||||
"Make_Header": "הפוך לכותרת",
|
||||
"Make_Ingredient": "הפוך למרכיב",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "נהל ספרים",
|
||||
"Manage_Emails": "נהל כתובות דואר אלקטרוני",
|
||||
"Meal_Plan": "תוכנית ארוחה",
|
||||
@@ -215,6 +238,8 @@
|
||||
"Message": "הודעה",
|
||||
"MissingProperties": "",
|
||||
"Month": "חודש",
|
||||
"MonthlyCredits": "",
|
||||
"MonthlyCreditsUsed": "",
|
||||
"Move": "העברה",
|
||||
"MoveCategory": "העבר אל: ",
|
||||
"Move_Down": "העברה למטה",
|
||||
@@ -251,6 +276,8 @@
|
||||
"Note": "הערה",
|
||||
"Number of Objects": "מספר אובייקטים",
|
||||
"Nutrition": "תזונה",
|
||||
"NutritionsPerServing": "",
|
||||
"NutritionsPerServingHelp": "",
|
||||
"OfflineAlert": "אתה במצב מנותק, רשימת הקניות לא בהכרח מסונכרנת.",
|
||||
"Ok": "אישור",
|
||||
"OnHand": "כרגע נגיש",
|
||||
@@ -327,6 +354,7 @@
|
||||
"Selected": "נבחר",
|
||||
"Servings": "מנות",
|
||||
"Settings": "הגדרות",
|
||||
"SettingsOnlySuperuser": "",
|
||||
"Share": "שיתוף",
|
||||
"ShoppingBackgroundSyncWarning": "בעיית תקשורת, מחכה לסנכון...",
|
||||
"Shopping_Categories": "קטגוריות קניות",
|
||||
@@ -343,9 +371,14 @@
|
||||
"Show_as_header": "הצג בתור כותרת",
|
||||
"Single": "בודד",
|
||||
"Size": "גודל",
|
||||
"Skip": "",
|
||||
"Social_Authentication": "אימות חברתי",
|
||||
"Sort_by_new": "סדר ע\"י חדש",
|
||||
"Space": "",
|
||||
"SpaceHelp": "",
|
||||
"SpaceMembersHelp": "",
|
||||
"SpaceName": "",
|
||||
"SpacePrivateObjectsHelp": "",
|
||||
"Space_Cosmetic_Settings": "חלק מהגדרות הקוסמטיות יכולות להיות מעודכנות על ידי מנהל המרחב וידרסו את הגדרות הקליינט עבור מרחב זה.",
|
||||
"Split_All_Steps": "פצל את כל השורות לצעדים נפרדים.",
|
||||
"StartDate": "תאריך התחלה",
|
||||
@@ -412,6 +445,8 @@
|
||||
"Week": "שבוע",
|
||||
"Week_Numbers": "מספר השבוע",
|
||||
"Welcome": "ברוכים הבאים",
|
||||
"WelcomeSettingsHelp": "",
|
||||
"WelcometoTandoor": "",
|
||||
"Year": "שנה",
|
||||
"Yes": "",
|
||||
"add_keyword": "הוסף מילת מפתח",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"AISettingsHostedHelp": "",
|
||||
"API": "API",
|
||||
"API_Browser": "",
|
||||
"API_Documentation": "",
|
||||
"Account": "Korisnički račun",
|
||||
"Active": "",
|
||||
"Add": "Dodaj",
|
||||
"AddChild": "",
|
||||
"AddFoodToShopping": "Dodaj {food} na svoj popis za kupovinu",
|
||||
@@ -16,6 +18,12 @@
|
||||
"Added_by": "Dodao",
|
||||
"Added_on": "Dodano",
|
||||
"Advanced": "Napredno",
|
||||
"AiCreditsBalance": "",
|
||||
"AiLog": "",
|
||||
"AiLogHelp": "",
|
||||
"AiModelHelp": "",
|
||||
"AiProvider": "",
|
||||
"AiProviderHelp": "",
|
||||
"Alignment": "Poravnanje",
|
||||
"Amount": "Količina",
|
||||
"App": "Aplikacija",
|
||||
@@ -57,6 +65,7 @@
|
||||
"Comments_setting": "Prikaži komentare",
|
||||
"Completed": "Završeno",
|
||||
"Conversion": "Konverzija",
|
||||
"ConvertUsingAI": "",
|
||||
"Copy": "Kopiraj",
|
||||
"Copy Link": "Kopiraj vezu",
|
||||
"Copy Token": "Kopiraj token",
|
||||
@@ -66,6 +75,8 @@
|
||||
"Create": "Stvori",
|
||||
"Create Food": "Kreiraj namirnicu",
|
||||
"Create Recipe": "Kreiraj recept",
|
||||
"CreateFirstRecipe": "",
|
||||
"CreateInvitation": "",
|
||||
"Create_Meal_Plan_Entry": "Kreirajte unos plana obroka",
|
||||
"Create_New_Food": "Dodaj novu namirnicu",
|
||||
"Create_New_Keyword": "Dodaj novu ključnu riječ",
|
||||
@@ -74,6 +85,7 @@
|
||||
"Create_New_Shopping_Category": "Dodaj novu kategoriju za kupovinu",
|
||||
"Create_New_Unit": "Dodaj novu jedinicu",
|
||||
"Created": "Stvoreno",
|
||||
"Credits": "",
|
||||
"Current_Period": "Trenutno razdoblje",
|
||||
"Custom Filter": "Prilagođeni filtar",
|
||||
"CustomImageHelp": "Učitaj sliku za prikaz u pregledu prostora.",
|
||||
@@ -143,10 +155,14 @@
|
||||
"Food_Alias": "Nadimci namirnice",
|
||||
"Food_Replace": "Zamjena namirnica",
|
||||
"Foods": "Namirnice",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "",
|
||||
"FulltextHelp": "",
|
||||
"Fuzzy": "",
|
||||
"FuzzySearchHelp": "",
|
||||
"Global": "",
|
||||
"GlobalHelp": "",
|
||||
"Group": "",
|
||||
"GroupBy": "Grupiraj po",
|
||||
"Hide_Food": "Sakrij namirnicu",
|
||||
"Hide_Keyword": "Sakrij ključne riječi",
|
||||
@@ -165,6 +181,9 @@
|
||||
"Image": "Slika",
|
||||
"Import": "Uvoz",
|
||||
"Import Recipe": "Uvezi recept",
|
||||
"ImportFirstRecipe": "",
|
||||
"ImportMealPlans": "",
|
||||
"ImportShoppingList": "",
|
||||
"Import_Error": "Došlo je do pogreške tijekom uvoza. Molimo proširite pojedinosti na dnu stranice kako bi vidjeli grešku.",
|
||||
"Import_Not_Yet_Supported": "Uvoz još nije podržan",
|
||||
"Import_Result_Info": "Uvezeno je {imported} od {total} recepata",
|
||||
@@ -195,13 +214,17 @@
|
||||
"Language": "Jezik",
|
||||
"Last_name": "Prezime",
|
||||
"Learn_More": "Saznajte više",
|
||||
"LeaveSpace": "",
|
||||
"Link": "Poveznica",
|
||||
"Load_More": "Učitaj više",
|
||||
"LogCredits": "",
|
||||
"LogCreditsHelp": "",
|
||||
"Log_Cooking": "Zapis kuhanja",
|
||||
"Log_Recipe_Cooking": "Dnevnik recepata kuhanja",
|
||||
"Logo": "Logotip",
|
||||
"Make_Header": "Napravi zaglavlje",
|
||||
"Make_Ingredient": "Napravi sastojak",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Upravljaj knjigama",
|
||||
"Manage_Emails": "Upravljanje e-poštom",
|
||||
"Meal_Plan": "Plan obroka",
|
||||
@@ -215,6 +238,8 @@
|
||||
"Message": "Poruka",
|
||||
"MissingProperties": "",
|
||||
"Month": "Mjesec",
|
||||
"MonthlyCredits": "",
|
||||
"MonthlyCreditsUsed": "",
|
||||
"Move": "Premjesti",
|
||||
"MoveCategory": "Premjesti u: ",
|
||||
"Move_Down": "Premjesti dolje",
|
||||
@@ -251,6 +276,8 @@
|
||||
"Note": "Bilješka",
|
||||
"Number of Objects": "Broj objekata",
|
||||
"Nutrition": "Nutritivna vrijednost",
|
||||
"NutritionsPerServing": "",
|
||||
"NutritionsPerServingHelp": "",
|
||||
"OfflineAlert": "Nisi na mreži, popis za kupnju se možda neće sinkronizirati.",
|
||||
"Ok": "Ok",
|
||||
"OnHand": "Trenutno pri ruci",
|
||||
@@ -327,6 +354,7 @@
|
||||
"Selected": "Odabrano",
|
||||
"Servings": "Porcije",
|
||||
"Settings": "Postavke",
|
||||
"SettingsOnlySuperuser": "",
|
||||
"Share": "Podijeli",
|
||||
"ShoppingBackgroundSyncWarning": "Loša mreža, čeka se sinkronizacija...",
|
||||
"Shopping_Categories": "Kategorije Kupovine",
|
||||
@@ -343,9 +371,14 @@
|
||||
"Show_as_header": "Prikaži kao zaglavlje",
|
||||
"Single": "Jedna",
|
||||
"Size": "Veličina",
|
||||
"Skip": "",
|
||||
"Social_Authentication": "Autentifikacija putem društvenih mreža",
|
||||
"Sort_by_new": "Poredaj po novom",
|
||||
"Space": "",
|
||||
"SpaceHelp": "",
|
||||
"SpaceMembersHelp": "",
|
||||
"SpaceName": "",
|
||||
"SpacePrivateObjectsHelp": "",
|
||||
"Space_Cosmetic_Settings": "Neke kozmetičke postavke mogu promijeniti administratori prostora i one će poništiti postavke klijenta za taj prostor.",
|
||||
"Split_All_Steps": "Podijeli sve retke u zasebne korake.",
|
||||
"StartDate": "Početni datum",
|
||||
@@ -412,6 +445,8 @@
|
||||
"Week": "Tjedan",
|
||||
"Week_Numbers": "Brojevi tjedana",
|
||||
"Welcome": "Dobrodošli",
|
||||
"WelcomeSettingsHelp": "",
|
||||
"WelcometoTandoor": "",
|
||||
"Year": "Godina",
|
||||
"Yes": "",
|
||||
"add_keyword": "Dodaj ključnu riječ",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"AISettingsHostedHelp": "",
|
||||
"API": "API",
|
||||
"API_Browser": "",
|
||||
"API_Documentation": "",
|
||||
"Account": "Fiók",
|
||||
"Active": "",
|
||||
"Add": "Hozzáadás",
|
||||
"AddChild": "",
|
||||
"AddFoodToShopping": "{food} hozzáadása bevásárlólistához",
|
||||
@@ -16,6 +18,12 @@
|
||||
"Added_by": "Hozzádta",
|
||||
"Added_on": "Hozzáadva",
|
||||
"Advanced": "Haladó",
|
||||
"AiCreditsBalance": "",
|
||||
"AiLog": "",
|
||||
"AiLogHelp": "",
|
||||
"AiModelHelp": "",
|
||||
"AiProvider": "",
|
||||
"AiProviderHelp": "",
|
||||
"Alignment": "Igazítás",
|
||||
"Amount": "Összeg",
|
||||
"App": "Applikáció",
|
||||
@@ -56,6 +64,7 @@
|
||||
"Comments_setting": "Hozzászólások megjelenítése",
|
||||
"Completed": "Kész",
|
||||
"Conversion": "Konverzió",
|
||||
"ConvertUsingAI": "",
|
||||
"Copy": "Másolás",
|
||||
"Copy Link": "Link másolása",
|
||||
"Copy Token": "Token másolása",
|
||||
@@ -64,6 +73,8 @@
|
||||
"Create": "Létrehozás",
|
||||
"Create Food": "Alapanyag létrehozása",
|
||||
"Create Recipe": "Recept létrehozása",
|
||||
"CreateFirstRecipe": "",
|
||||
"CreateInvitation": "",
|
||||
"Create_Meal_Plan_Entry": "Menüterv bejegyzés létrehozása",
|
||||
"Create_New_Food": "Új alapanyag hozzáadása",
|
||||
"Create_New_Keyword": "Új kulcsszó hozzáadása",
|
||||
@@ -71,6 +82,7 @@
|
||||
"Create_New_Shopping Category": "Új vásárlási kategória létrehozása",
|
||||
"Create_New_Shopping_Category": "Új vásárlási kategória hozzáadása",
|
||||
"Create_New_Unit": "Új mértékegység hozzáadása",
|
||||
"Credits": "",
|
||||
"Current_Period": "Jelenlegi periódus",
|
||||
"Custom Filter": "Egyéni szűrő",
|
||||
"DELETE_ERROR": "",
|
||||
@@ -126,10 +138,14 @@
|
||||
"Food_Alias": "",
|
||||
"Food_Replace": "Étel cseréje",
|
||||
"Foods": "Alapanyagok",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "",
|
||||
"FulltextHelp": "",
|
||||
"Fuzzy": "",
|
||||
"FuzzySearchHelp": "",
|
||||
"Global": "",
|
||||
"GlobalHelp": "",
|
||||
"Group": "",
|
||||
"GroupBy": "Csoportosítva",
|
||||
"Hide_Food": "Alapanyag elrejtése",
|
||||
"Hide_Keyword": "Kulcsszavak elrejtése",
|
||||
@@ -148,6 +164,9 @@
|
||||
"Image": "Kép",
|
||||
"Import": "Import",
|
||||
"Import Recipe": "Recept importálása",
|
||||
"ImportFirstRecipe": "",
|
||||
"ImportMealPlans": "",
|
||||
"ImportShoppingList": "",
|
||||
"Import_Error": "Hiba történt az importálás során. Kérjük, a megtekintéshez bontsa ki az oldal alján található Részletek menüpontot.",
|
||||
"Import_Not_Yet_Supported": "",
|
||||
"Import_Result_Info": "{total}-ból/ből {imported} recept importálva",
|
||||
@@ -177,12 +196,16 @@
|
||||
"Language": "Nyelv",
|
||||
"Last_name": "Vezetéknév",
|
||||
"Learn_More": "Tudjon meg többet",
|
||||
"LeaveSpace": "",
|
||||
"Link": "Link",
|
||||
"Load_More": "Továbbiak betöltése",
|
||||
"LogCredits": "",
|
||||
"LogCreditsHelp": "",
|
||||
"Log_Cooking": "Főzés naplózása",
|
||||
"Log_Recipe_Cooking": "Főzés naplózása",
|
||||
"Make_Header": "Átalakítás címsorra",
|
||||
"Make_Ingredient": "Összetevő létrehozása",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Könyvek kezelése",
|
||||
"Manage_Emails": "Levelezés kezelése",
|
||||
"Meal_Plan": "Menüterv",
|
||||
@@ -196,6 +219,8 @@
|
||||
"Message": "Üzenet",
|
||||
"MissingProperties": "",
|
||||
"Month": "Hónap",
|
||||
"MonthlyCredits": "",
|
||||
"MonthlyCreditsUsed": "",
|
||||
"Move": "Mozgatás",
|
||||
"MoveCategory": "Áthelyezés ide: ",
|
||||
"Move_Down": "Lefelé mozgatás",
|
||||
@@ -228,6 +253,8 @@
|
||||
"Note": "Megjegyzés",
|
||||
"Number of Objects": "Objektumok száma",
|
||||
"Nutrition": "Tápérték",
|
||||
"NutritionsPerServing": "",
|
||||
"NutritionsPerServingHelp": "",
|
||||
"OfflineAlert": "Ön éppen offline állapotban van, a bevásárlólista nem biztos, hogy szinkronizálódik.",
|
||||
"Ok": "Ok",
|
||||
"OnHand": "Jelenleg készleten",
|
||||
@@ -300,6 +327,7 @@
|
||||
"Selected": "Kiválasztott",
|
||||
"Servings": "Adag",
|
||||
"Settings": "Beállítások",
|
||||
"SettingsOnlySuperuser": "",
|
||||
"Share": "Megosztás",
|
||||
"Shopping_Categories": "Vásárlási kategóriák",
|
||||
"Shopping_Category": "Vásárlási kategória",
|
||||
@@ -311,8 +339,13 @@
|
||||
"Show_as_header": "Megjelenítés címként",
|
||||
"Single": "Egyetlen",
|
||||
"Size": "Méret",
|
||||
"Skip": "",
|
||||
"Sort_by_new": "Rendezés legújabbak szerint",
|
||||
"Space": "",
|
||||
"SpaceHelp": "",
|
||||
"SpaceMembersHelp": "",
|
||||
"SpaceName": "",
|
||||
"SpacePrivateObjectsHelp": "",
|
||||
"Split_All_Steps": "Ossza fel az összes sort különálló lépésekbe.",
|
||||
"StartDate": "Kezdés dátuma",
|
||||
"Starting_Day": "A hét kezdőnapja",
|
||||
@@ -372,6 +405,8 @@
|
||||
"Week": "Hét",
|
||||
"Week_Numbers": "",
|
||||
"Welcome": "Üdvözöljük",
|
||||
"WelcomeSettingsHelp": "",
|
||||
"WelcometoTandoor": "",
|
||||
"Year": "Év",
|
||||
"Yes": "",
|
||||
"add_keyword": "Kulcsszó hozzáadása",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"AISettingsHostedHelp": "",
|
||||
"API_Browser": "",
|
||||
"API_Documentation": "",
|
||||
"Active": "",
|
||||
"Add": "",
|
||||
"AddChild": "",
|
||||
"Add_nutrition_recipe": "Ավելացնել սննդայնություն բաղադրատոմսին",
|
||||
@@ -8,6 +10,12 @@
|
||||
"Add_to_Plan": "Ավելացնել պլանին",
|
||||
"Add_to_Shopping": "Ավելացնել գնումներին",
|
||||
"Advanced Search Settings": "Ընդլայնված փնտրման կարգավորումներ",
|
||||
"AiCreditsBalance": "",
|
||||
"AiLog": "",
|
||||
"AiLogHelp": "",
|
||||
"AiModelHelp": "",
|
||||
"AiProvider": "",
|
||||
"AiProviderHelp": "",
|
||||
"Apply": "",
|
||||
"Automate": "Ավտոմատացնել",
|
||||
"BatchDeleteConfirm": "",
|
||||
@@ -22,11 +30,15 @@
|
||||
"Categories": "",
|
||||
"Category": "",
|
||||
"Close": "",
|
||||
"ConvertUsingAI": "",
|
||||
"Copy": "",
|
||||
"Create": "Ստեղծել",
|
||||
"CreateFirstRecipe": "",
|
||||
"CreateInvitation": "",
|
||||
"Create_New_Food": "Ավելացնել նոր սննդամթերք",
|
||||
"Create_New_Keyword": "Ավելացնել նոր բանալի բառ",
|
||||
"Create_New_Shopping Category": "Ստեղծել գնումների նոր կատեգորիա",
|
||||
"Credits": "",
|
||||
"DELETE_ERROR": "",
|
||||
"Date": "",
|
||||
"Delete": "",
|
||||
@@ -51,10 +63,14 @@
|
||||
"File": "",
|
||||
"Files": "",
|
||||
"Food": "Սննդամթերք",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "",
|
||||
"FulltextHelp": "",
|
||||
"Fuzzy": "",
|
||||
"FuzzySearchHelp": "",
|
||||
"Global": "",
|
||||
"GlobalHelp": "",
|
||||
"Group": "",
|
||||
"Hide_Food": "Թաքցնել սննդամթերքը",
|
||||
"Hide_Keywords": "Թաքցնել բանալի բառը",
|
||||
"Hide_Recipes": "Թաքցնել բաղադրատոմսերը",
|
||||
@@ -63,20 +79,29 @@
|
||||
"IgnoreAccents": "",
|
||||
"IgnoreAccentsHelp": "",
|
||||
"Import": "Ներմուծել",
|
||||
"ImportFirstRecipe": "",
|
||||
"ImportMealPlans": "",
|
||||
"ImportShoppingList": "",
|
||||
"Import_finished": "Ներմուծումն ավարտված է",
|
||||
"Information": "Տեղեկություն",
|
||||
"Ingredients": "",
|
||||
"Keywords": "",
|
||||
"LeaveSpace": "",
|
||||
"Link": "",
|
||||
"Load_More": "",
|
||||
"LogCredits": "",
|
||||
"LogCreditsHelp": "",
|
||||
"Log_Cooking": "Գրանցել եփելը",
|
||||
"Log_Recipe_Cooking": "Գրանցել բաղադրատոմսի օգտագործում",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Կարգավորել Գրքերը",
|
||||
"Meal_Plan": "Ճաշացուցակ",
|
||||
"Merge": "Միացնել",
|
||||
"MergeAutomateHelp": "",
|
||||
"Merge_Keyword": "Միացնել բանալի բառը",
|
||||
"MissingProperties": "",
|
||||
"MonthlyCredits": "",
|
||||
"MonthlyCreditsUsed": "",
|
||||
"Move": "Տեղափոխել",
|
||||
"Move_Food": "Տեղափոխել սննդամթերքը",
|
||||
"Move_Keyword": "Տեղափոխել բանալի բառը",
|
||||
@@ -89,6 +114,8 @@
|
||||
"NoUnit": "",
|
||||
"No_Results": "Արդյունքներ չկան",
|
||||
"Nutrition": "",
|
||||
"NutritionsPerServing": "",
|
||||
"NutritionsPerServingHelp": "",
|
||||
"Ok": "",
|
||||
"Open": "",
|
||||
"Parent": "Ծնող",
|
||||
@@ -123,13 +150,19 @@
|
||||
"Selected": "",
|
||||
"Servings": "",
|
||||
"Settings": "Կարգավորումներ",
|
||||
"SettingsOnlySuperuser": "",
|
||||
"Share": "",
|
||||
"Shopping_Category": "Գնումների կատեգորիա",
|
||||
"Shopping_list": "Գնումների ցուցակ",
|
||||
"Show_as_header": "Ցույց տալ որպես խորագիր",
|
||||
"Size": "",
|
||||
"Skip": "",
|
||||
"Sort_by_new": "Տեսակավորել ըստ նորերի",
|
||||
"Space": "",
|
||||
"SpaceHelp": "",
|
||||
"SpaceMembersHelp": "",
|
||||
"SpaceName": "",
|
||||
"SpacePrivateObjectsHelp": "",
|
||||
"StartsWith": "",
|
||||
"StartsWithHelp": "",
|
||||
"Step": "",
|
||||
@@ -149,6 +182,8 @@
|
||||
"View_Recipes": "Դիտել բաղադրատոմսերը",
|
||||
"Visibility": "",
|
||||
"Waiting": "",
|
||||
"WelcomeSettingsHelp": "",
|
||||
"WelcometoTandoor": "",
|
||||
"Yes": "",
|
||||
"all_fields_optional": "Բոլոր տողերը կամավոր են և կարող են մնալ դատարկ։",
|
||||
"and": "և",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user