Compare commits

..

16 Commits

Author SHA1 Message Date
vabene1111
ad6fe5fa4d Merge branch 'develop' into beta 2025-07-18 15:48:02 +02:00
vabene1111
ac31c112f3 Merge branch 'develop' into beta 2025-07-11 21:59:18 +02:00
vabene1111
0104b600cc Merge branch 'develop' into beta 2025-07-07 18:28:50 +02:00
vabene1111
7baad85112 Merge branch 'develop' into beta 2025-06-22 10:35:01 +02:00
vabene1111
4b0bfa9a85 Merge branch 'master' into beta 2025-06-22 10:29:43 +02:00
vabene1111
5e7c75ef68 Merge branch 'develop' into beta 2025-01-18 09:24:08 +01:00
vabene1111
954a35bea2 Merge branch 'develop' into beta 2025-01-01 08:17:32 +01:00
vabene1111
88347d44c8 Merge branch 'beta' of https://github.com/TandoorRecipes/recipes into beta 2024-11-23 21:56:13 +01:00
vabene1111
2c13e76fbb Merge branch 'develop' into beta 2024-03-05 08:54:58 +01:00
vabene1111
362f634828 Merge branch 'develop' into beta 2024-03-02 07:41:28 +01:00
vabene1111
2fb968cfd3 Merge branch 'develop' into beta 2024-03-01 07:42:28 +01:00
vabene1111
4d3dab6edd Merge branch 'develop' into beta 2024-02-28 17:21:22 +01:00
vabene1111
8f1b593ad1 Merge branch 'develop' into beta 2024-02-28 17:19:15 +01:00
vabene1111
1002f0d61f Merge branch 'develop' into beta 2024-02-28 17:12:35 +01:00
vabene1111
20cb218688 Merge branch 'develop' into beta 2024-02-26 16:29:16 +01:00
vabene1111
bba44b0c1e Merge branch 'develop' into beta 2024-02-20 07:54:28 +01:00
174 changed files with 5948 additions and 11738 deletions

View File

@@ -74,8 +74,9 @@ jobs:
flavor: |
latest=false
suffix=${{ matrix.suffix }}
# disable latest for tagged releases while in beta
# type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
tags: |
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
@@ -93,34 +94,34 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
notify-stable:
name: Notify Stable
runs-on: ubuntu-latest
needs: build-container
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Set tag name
run: |
# Strip "refs/tags/" prefix
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
# Send stable discord notification
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: '🚀 Version {{ VERSION }} of tandoor has been released 🥳 Check it out https://github.com/vabene1111/recipes/releases/tag/{{ VERSION }}'
# notify-stable:
# name: Notify Stable
# runs-on: ubuntu-latest
# needs: build-container
# if: startsWith(github.ref, 'refs/tags/')
# steps:
# - name: Set tag name
# run: |
# # Strip "refs/tags/" prefix
# echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
# # Send stable discord notification
# - name: Discord notification
# env:
# DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
# uses: Ilshidur/action-discord@0.3.2
# with:
# args: '🚀 Version {{ VERSION }} of tandoor has been released 🥳 Check it out https://github.com/vabene1111/recipes/releases/tag/{{ VERSION }}'
notify-beta:
name: Notify Beta
runs-on: ubuntu-latest
needs: build-container
if: github.ref == 'refs/heads/beta'
if: startsWith(github.ref, 'refs/tags/')
steps:
# Send beta discord notification
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_BETA_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
uses: Ilshidur/action-discord@0.3.2
with:
args: '🚀 The Tandoor 2 Image has been updated! 🥳'

View File

@@ -13,7 +13,7 @@ jobs:
node-version: ["22"]
steps:
- uses: actions/checkout@v4
- uses: awalsh128/cache-apt-pkgs-action@v1.5.1
- uses: awalsh128/cache-apt-pkgs-action@v1.4.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
@@ -38,7 +38,7 @@ jobs:
./cookbook/static
./staticfiles
key: |
${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue3/src/*') }}
${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue/src/*') }}
# Build Vue frontend & Dependencies
- name: Set up Node ${{ matrix.node-version }}

3
.gitignore vendored
View File

@@ -89,6 +89,3 @@ venv/
.idea/easy-i18n.xml
cookbook/static/vue3
vue3/node_modules
cookbook/tests/other/docs/reports/tests/tests.html
cookbook/tests/other/docs/reports/tests/pytest.xml
vue3/src/plugins

View File

@@ -1,14 +1,15 @@
FROM python:3.13-alpine3.22
FROM python:3.13-alpine3.21
#Install all dependencies.
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git libgcc libstdc++ nginx tini envsubst nodejs npm
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git libgcc libstdc++
#Print all logs without buffering it.
ENV PYTHONUNBUFFERED=1 \
DOCKER=true
ENV PYTHONUNBUFFERED 1
ENV DOCKER true
#This port will be used by gunicorn.
EXPOSE 80 8080
EXPOSE 8080
#Create app dir and install requirements.
RUN mkdir /opt/recipes
@@ -16,27 +17,29 @@ WORKDIR /opt/recipes
COPY requirements.txt ./
RUN \
if [ `apk --print-arch` = "armv7" ]; then \
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
fi
# remove Development dependencies from requirements.txt
RUN sed -i '/# Development/,$d' requirements.txt
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 && \
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 && \
echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \
python -m venv venv && \
/opt/recipes/venv/bin/python -m pip install --upgrade pip && \
venv/bin/pip debug -v && \
venv/bin/pip install wheel==0.45.1 && \
venv/bin/pip install setuptools_rust==1.10.2 && \
if [ `apk --print-arch` = "aarch64" ]; then \
curl https://sh.rustup.rs -sSf | sh -s -- -y; \
fi &&\
venv/bin/pip install -r requirements.txt --no-cache-dir &&\
apk --purge del .build-deps
#Copy project and execute it.
COPY . ./
# delete default nginx config and link it to tandoors config
# create symlinks to access and error log to show them on stdout
RUN rm -rf /etc/nginx/http.d && \
ln -s /opt/recipes/http.d /etc/nginx/http.d && \
ln -sf /dev/stdout /var/log/nginx/access.log && \
ln -sf /dev/stderr /var/log/nginx/error.log
# commented for now https://github.com/TandoorRecipes/recipes/issues/3478
#HEALTHCHECK --interval=30s \
# --timeout=5s \
@@ -50,4 +53,4 @@ RUN /opt/recipes/venv/bin/python version.py
RUN find . -type d -name ".git" | xargs rm -rf
RUN chmod +x boot.sh
ENTRYPOINT ["/sbin/tini", "--", "/opt/recipes/boot.sh"]
ENTRYPOINT ["/opt/recipes/boot.sh"]

View File

@@ -23,7 +23,6 @@
<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://community.tandoor.dev" target="_blank" rel="noopener noreferrer">Community</a> •
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer">Discord</a>
</p>
@@ -82,13 +81,13 @@ Share some information on how you use Tandoor to help me improve the application
<table>
<tr>
<td><a href="https://community.tandoor.dev">Community</a></td>
<td>Get support, share best practices, discuss feature ideas, and meet other Tandoor users.</td>
<td><a href="https://discord.gg/RhzBrfWgtp">Discord</a></td>
<td>We have a public Discord server that anyone can join. This is where all our developers and contributors hang out and where we make announcements</td>
</tr>
<tr>
<td><a href="https://discord.gg/RhzBrfWgtp">Discord</a></td>
<td>We have a public Discord server that anyone can join. This is where all our developers and contributors hang out and where we make announcements</td>
<td><a href="https://twitter.com/TandoorRecipes">Twitter</a></td>
<td>You can follow our Twitter account to get updates on new features or releases</td>
</tr>
</table>

35
boot.sh Executable file → Normal file
View File

@@ -1,21 +1,11 @@
#!/bin/sh
source venv/bin/activate
# these are envsubst in the nginx config, make sure they default to something sensible when unset
export TANDOOR_PORT="${TANDOOR_PORT:-8080}"
export MEDIA_ROOT=${MEDIA_ROOT:-/opt/recipes/mediafiles};
export STATIC_ROOT=${STATIC_ROOT:-/opt/recipes/staticfiles};
TANDOOR_PORT="${TANDOOR_PORT:-8080}"
GUNICORN_WORKERS="${GUNICORN_WORKERS:-3}"
GUNICORN_THREADS="${GUNICORN_THREADS:-2}"
GUNICORN_LOG_LEVEL="${GUNICORN_LOG_LEVEL:-'info'}"
PLUGINS_BUILD="${PLUGINS_BUILD:-0}"
if [ "${TANDOOR_PORT}" -eq 80 ]; then
echo "TANDOOR_PORT set to 8080 because 80 is now taken by the integrated nginx"
TANDOOR_PORT=8080
fi
NGINX_CONF_FILE=/opt/recipes/nginx/conf.d/Recipes.conf
display_warning() {
echo "[WARNING]"
@@ -24,6 +14,11 @@ display_warning() {
echo "Checking configuration..."
# Nginx config file must exist if gunicorn is not active
if [ ! -f "$NGINX_CONF_FILE" ] && [ $GUNICORN_MEDIA -eq 0 ]; then
display_warning "Nginx configuration file could not be found at the default location!\nPath: ${NGINX_CONF_FILE}"
fi
# SECRET_KEY (or a valid file at SECRET_KEY_FILE) must be set in .env file
if [ -f "${SECRET_KEY_FILE}" ]; then
@@ -84,12 +79,8 @@ echo "Database is ready"
echo "Migrating database"
python manage.py migrate
if [ "${PLUGINS_BUILD}" -eq 1 ]; then
echo "Running yarn build at startup because PLUGINS_BUILD is enabled"
python plugin.py
fi
python manage.py migrate
echo "Collecting static files, this may take a while..."
@@ -97,18 +88,10 @@ python manage.py collectstatic --noinput
echo "Done"
chmod -R 755 ${MEDIA_ROOT:-/opt/recipes/mediafiles}
chmod -R 755 /opt/recipes/mediafiles
ipv6_disable=$(cat /sys/module/ipv6/parameters/disable)
# prepare nginx config
envsubst '$MEDIA_ROOT $STATIC_ROOT $TANDOOR_PORT' < /opt/recipes/http.d/Recipes.conf.template > /opt/recipes/http.d/Recipes.conf
# start nginx
echo "Starting nginx"
nginx
echo "Starting gunicorn"
# Check if IPv6 is enabled, only then run gunicorn with ipv6 support
if [ "$ipv6_disable" -eq 0 ]; then
exec gunicorn -b "[::]:$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi

View File

@@ -176,6 +176,7 @@ class IngredientParser:
# if something like this is detected move it to the beginning so the parser can handle it
if len(ingredient) < 1000 and re.search(r'^([^\W\d_])+(.)*[1-9](\d)*\s*([^\W\d_])+', ingredient):
match = re.search(r'[1-9](\d)*\s*([^\W\d_])+', ingredient)
print(f'reording from {ingredient} to {ingredient[match.start():match.end()] + " " + ingredient.replace(ingredient[match.start():match.end()], "")}')
ingredient = ingredient[match.start():match.end()] + ' ' + ingredient.replace(ingredient[match.start():match.end()], '')
# if the string contains parenthesis early on remove it and place it at the end
@@ -210,46 +211,44 @@ class IngredientParser:
# three arguments if it already has a unit there can't be
# a fraction for the amount
if len(tokens) > 2:
never_unit_applied = False
if not self.ignore_rules:
tokens, never_unit_applied = self.automation.apply_never_unit_automation(tokens)
if never_unit_applied:
unit = tokens[1]
food, note = self.parse_food(tokens[2:])
else:
try:
if unit is not None:
# a unit is already found, no need to try the second argument for a fraction
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except
raise ValueError
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½'
if tokens[1]:
amount += self.parse_fraction(tokens[1])
# assume that units can't end with a comma
if len(tokens) > 3 and not tokens[2].endswith(','):
# try to use third argument as unit and everything else as food, use everything as food if it fails
try:
food, note = self.parse_food(tokens[3:])
unit = tokens[2]
except ValueError:
if never_unit_applied:
unit = tokens[1]
food, note = self.parse_food(tokens[2:])
else:
try:
if unit is not None:
# a unit is already found, no need to try the second argument for a fraction
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except
raise ValueError
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½'
if tokens[1]:
amount += self.parse_fraction(tokens[1])
# assume that units can't end with a comma
if len(tokens) > 3 and not tokens[2].endswith(','):
# try to use third argument as unit and everything else as food, use everything as food if it fails
try:
food, note = self.parse_food(tokens[3:])
unit = tokens[2]
except ValueError:
food, note = self.parse_food(tokens[2:])
else:
food, note = self.parse_food(tokens[2:])
else:
food, note = self.parse_food(tokens[2:])
except ValueError:
# assume that units can't end with a comma
if not tokens[1].endswith(','):
# try to use second argument as unit and everything else as food, use everything as food if it fails
try:
food, note = self.parse_food(tokens[2:])
if unit is None:
unit = tokens[1]
else:
note = tokens[1]
except ValueError:
except ValueError:
# assume that units can't end with a comma
if not tokens[1].endswith(','):
# try to use second argument as unit and everything else as food, use everything as food if it fails
try:
food, note = self.parse_food(tokens[2:])
if unit is None:
unit = tokens[1]
else:
note = tokens[1]
except ValueError:
food, note = self.parse_food(tokens[1:])
else:
food, note = self.parse_food(tokens[1:])
else:
food, note = self.parse_food(tokens[1:])
else:
# only two arguments, first one is the amount
# which means this is the food
@@ -270,7 +269,6 @@ class IngredientParser:
if food and not self.ignore_rules:
food = self.automation.apply_food_automation(food)
if len(food) > Food._meta.get_field('name').max_length: # test if food name is to long
# try splitting it at a space and taking only the first arg
if len(food.split()) > 1 and len(food.split()[0]) < Food._meta.get_field('name').max_length:
@@ -283,4 +281,6 @@ class IngredientParser:
if len(food.strip()) == 0:
raise ValueError(f'Error parsing string {ingredient}, food cannot be empty')
print(f'parsed {ingredient} to {amount} - {unit} - {food} - {note}')
return amount, unit, food, note[:Ingredient._meta.get_field('note').max_length].strip()

View File

@@ -62,14 +62,9 @@ class FoodPropertyHelper:
computed_properties[pt.id]['food_values'] = self.add_or_create(
computed_properties[p.property_type.id]['food_values'], c.food.id, (c.amount / i.food.properties_food_amount) * p.property_amount, c.food)
if not found_property:
# if no amount and food does not exist yet add it but don't count as missing
if i.amount == 0 or i.no_amount and i.food.id not in computed_properties[pt.id]['food_values']:
if i.amount == 0 or i.no_amount: # don't count ingredients without an amount as missing
computed_properties[pt.id]['missing_value'] = computed_properties[pt.id]['missing_value'] or False # don't override if another food was already missing
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': 0}
# if amount is present but unit is missing indicate it in the result
elif i.unit is None:
if i.food.id not in computed_properties[pt.id]['food_values']:
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': 0}
computed_properties[pt.id]['food_values'][i.food.id]['missing_unit'] = True
else:
computed_properties[pt.id]['missing_value'] = True
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': None}

View File

@@ -14,8 +14,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"PO-Revision-Date: 2025-08-10 11:36+0000\n"
"Last-Translator: Enzo La Rafale <enzo.chaussivert@gmail.com>\n"
"PO-Revision-Date: 2025-02-16 14:58+0000\n"
"Last-Translator: Elvis Gosselin <elvis.gosselin@tutanota.com>\n"
"Language-Team: French <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/fr/>\n"
"Language: fr\n"
@@ -405,7 +405,7 @@ msgstr "Rubrique"
#: .\cookbook\management\commands\fix_duplicate_properties.py:15
msgid "Fixes foods with "
msgstr "Corriger les aliments avec "
msgstr ""
#: .\cookbook\management\commands\rebuildindex.py:14
msgid "Rebuilds full text search index on Recipe"
@@ -442,6 +442,8 @@ msgid "Other"
msgstr "Autre"
#: .\cookbook\migrations\0190_auto_20230525_1506.py:17
#, fuzzy
#| msgid "Fats"
msgid "Fat"
msgstr "Matières grasses"
@@ -449,7 +451,7 @@ msgstr "Matières grasses"
#: .\cookbook\migrations\0190_auto_20230525_1506.py:18
#: .\cookbook\migrations\0190_auto_20230525_1506.py:19
msgid "g"
msgstr "g"
msgstr ""
#: .\cookbook\migrations\0190_auto_20230525_1506.py:18
msgid "Carbohydrates"
@@ -465,7 +467,7 @@ msgstr "Calories"
#: .\cookbook\migrations\0190_auto_20230525_1506.py:20
msgid "kcal"
msgstr "kcal"
msgstr ""
#: .\cookbook\models.py:325
msgid ""
@@ -552,24 +554,30 @@ msgid "Instruction Replace"
msgstr "Remplacer l'instruction"
#: .\cookbook\models.py:1472
#, fuzzy
#| msgid "New Unit"
msgid "Never Unit"
msgstr "Aucune unité"
msgstr "Nouvelle unité"
#: .\cookbook\models.py:1473
msgid "Transpose Words"
msgstr "Transposer les mots"
msgstr ""
#: .\cookbook\models.py:1474
#, fuzzy
#| msgid "Food Alias"
msgid "Food Replace"
msgstr "Aliment alternatif"
msgstr "Aliment équivalent"
#: .\cookbook\models.py:1475
#, fuzzy
#| msgid "Description Replace"
msgid "Unit Replace"
msgstr "Remplacer l'unité"
msgstr "Remplacer la Description"
#: .\cookbook\models.py:1476
msgid "Name Replace"
msgstr "Remplacer le nom"
msgstr ""
#: .\cookbook\models.py:1503 .\cookbook\views\delete.py:40
#: .\cookbook\views\edit.py:210 .\cookbook\views\new.py:39
@@ -1032,8 +1040,10 @@ msgid "Properties"
msgstr "Propriétés"
#: .\cookbook\templates\base.html:301 .\cookbook\views\lists.py:255
#, fuzzy
#| msgid "Account Connections"
msgid "Unit Conversions"
msgstr "Conversions d'unités"
msgstr "Comptes connectés"
#: .\cookbook\templates\base.html:318 .\cookbook\templates\index.html:47
msgid "Import Recipe"
@@ -1053,8 +1063,10 @@ msgid "Space Settings"
msgstr "Paramètres de groupe"
#: .\cookbook\templates\base.html:340
#, fuzzy
#| msgid "External Recipes"
msgid "External Connectors"
msgstr "Connecteurs externes"
msgstr "Recettes externes"
#: .\cookbook\templates\base.html:345 .\cookbook\templates\system.html:13
msgid "System"
@@ -1518,8 +1530,10 @@ msgid "Back"
msgstr "Retour"
#: .\cookbook\templates\property_editor.html:7
#, fuzzy
#| msgid "Ingredient Editor"
msgid "Property Editor"
msgstr "Éditeur de propriété"
msgstr "Éditeur dingrédients"
#: .\cookbook\templates\recipe_view.html:36
msgid "Comments"
@@ -1997,8 +2011,10 @@ msgid "Sign in using"
msgstr "Se connecter avec"
#: .\cookbook\templates\space_manage.html:7
#, fuzzy
#| msgid "Space Membership"
msgid "Space Management"
msgstr "Gestion de l'espace"
msgstr "Adhésion à l'espace"
#: .\cookbook\templates\space_manage.html:26
msgid "Space:"
@@ -2211,8 +2227,10 @@ msgid "Info"
msgstr "Info"
#: .\cookbook\templates\system.html:110 .\cookbook\templates\system.html:127
#, fuzzy
#| msgid "Use fractions"
msgid "Migrations"
msgstr "Migrations"
msgstr "Utiliser les fractions"
#: .\cookbook\templates\system.html:116
msgid ""
@@ -2247,8 +2265,10 @@ msgid "Hide"
msgstr "Cacher"
#: .\cookbook\templates\system.html:210
#, fuzzy
#| msgid "Show Log"
msgid "Show"
msgstr "Afficher"
msgstr "Afficher le journal"
#: .\cookbook\templates\url_import.html:8
msgid "URL Import"
@@ -2333,9 +2353,11 @@ msgstr ""
"MM-DD."
#: .\cookbook\views\api.py:744
#, fuzzy
#| msgid "ID of recipe a step is part of. For multiple repeat parameter."
msgid "Filter meal plans with MealType ID. For multiple repeat parameter."
msgstr ""
"Filtrer le planning des repas avec l'identifiant MealType. Pour plusieurs "
"Identifiant de la recette dont fait partie une étape. Pour plusieurs "
"paramètres de répétition."
#: .\cookbook\views\api.py:872
@@ -2438,27 +2460,18 @@ msgstr ""
#: .\cookbook\views\api.py:922
msgid "ID of book a recipe should be in. For multiple repeat parameter."
msgstr ""
"ID du livre dans lequel une recette doit se trouver. Pour plusieurs "
"paramètres de répétition."
#: .\cookbook\views\api.py:923
msgid "Book IDs, repeat for multiple. Return recipes with any of the books"
msgstr ""
"IDs de livre, répéter pour plusieurs livres. Renvoie les recettes dans "
"n'importe quel livre."
#: .\cookbook\views\api.py:924
msgid "Book IDs, repeat for multiple. Return recipes with all of the books."
msgstr ""
"IDs de livre, répéter pour plusieurs livres. Renvoie les recettes dans tous "
"les livre."
#: .\cookbook\views\api.py:925
#, fuzzy
msgid "Book IDs, repeat for multiple. Exclude recipes with any of the books."
msgstr ""
"Identifiants de livres : répéter pour plusieurs. Exclure les recettes de "
"l'un des livres."
#: .\cookbook\views\api.py:926
msgid "Book IDs, repeat for multiple. Exclude recipes with all of the books."

View File

@@ -11,7 +11,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"PO-Revision-Date: 2025-07-21 09:43+0000\n"
"PO-Revision-Date: 2024-11-05 10:58+0000\n"
"Last-Translator: Aija Kozlovska <kozlovska.aija@gmail.com>\n"
"Language-Team: Latvian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/lv/>\n"
@@ -20,7 +20,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2;\n"
"X-Generator: Weblate 5.8.4\n"
"X-Generator: Weblate 5.6.2\n"
#: .\cookbook\forms.py:45
msgid ""
@@ -83,8 +83,8 @@ msgid ""
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
"php/webdav/</code> is added automatically)"
msgstr ""
"Atstājiet tukšu Dropbox un ievadiet tikai Nextcloud bāzes URL (<code> /remote"
".php/webdav/ </code> tiek pievienots automātiski)"
"Atstājiet tukšu Dropbox un ievadiet tikai Nextcloud bāzes URL (<kods> /"
"remote.php/webdav/ </code> tiek pievienots automātiski)"
#: .\cookbook\forms.py:188
msgid ""
@@ -147,65 +147,48 @@ msgid ""
"Determines how fuzzy a search is if it uses trigram similarity matching (e."
"g. low values mean more typos are ignored)."
msgstr ""
"Nosaka cik precīza ir meklēšana gadījumā, ja tiek izmantota trigram līdzība ("
"jo zemāka vērtība, jo vairāk rakstīšanas kļūdas tiek ignorētas)."
#: .\cookbook\forms.py:340
msgid ""
"Select type method of search. Click <a href=\"/docs/search/\">here</a> for "
"full description of choices."
msgstr ""
"Izvēlies meklēšanas veidu. Spied <a href=\"/docs/search/\">šeit</a>, lai "
"apskatītu visas iespējas."
#: .\cookbook\forms.py:341
msgid ""
"Use fuzzy matching on units, keywords and ingredients when editing and "
"importing recipes."
msgstr ""
"Izmanto aptuveno meklēšanu vienībām, atslēgas vārdiem un sastāvdaļām "
"importējot un labojot receptes."
#: .\cookbook\forms.py:342
msgid ""
"Fields to search ignoring accents. Selecting this option can improve or "
"degrade search quality depending on language"
msgstr ""
"Lauki, kurus meklējot ignorēt akcentus. Šī varianta izvēlēšanās var uzlabot "
"vai pasliktināt meklēšanas kvalitāti atkarībā no valodas"
#: .\cookbook\forms.py:343
msgid ""
"Fields to search for partial matches. (e.g. searching for 'Pie' will return "
"'pie' and 'piece' and 'soapie')"
msgstr ""
"Lauki, kuros meklēt aptuveno līdzību. (piem. meklējot vārdu 'Kūka' tiks "
"atrasts arī 'kūka' un 'ābolkūka')"
#: .\cookbook\forms.py:344
msgid ""
"Fields to search for beginning of word matches. (e.g. searching for 'sa' "
"will return 'salad' and 'sandwich')"
msgstr ""
"Lauki, kuros meklēt vārdu līdzības sākumu. (piem meklējot 'la' atradīt "
"'lapas' un 'laims')"
#: .\cookbook\forms.py:345
msgid ""
"Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) "
"Note: this option will conflict with 'web' and 'raw' methods of search."
msgstr ""
"Lauki, kuriem izmantot aptuveno meklēšanu. (piem. meklējot 'recpte' tiks "
"atrasts 'recepte'.) Piezīme: šis variants konfliktēs ar 'web' un 'raw' "
"meklēšanas metodēm."
#: .\cookbook\forms.py:346
msgid ""
"Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods "
"only function with fulltext fields."
msgstr ""
"Lauki priekš pilnās teksta meklēšanas. Piezīme: 'web', 'phrase' un 'raw' "
"meklēšanas metodes darbojās tikai ar pilno teksta meklēšanu."
#: .\cookbook\forms.py:350
#, fuzzy
@@ -215,11 +198,11 @@ msgstr "Meklēt"
#: .\cookbook\forms.py:350
msgid "Fuzzy Lookups"
msgstr "Aptuvenā meklēšana"
msgstr ""
#: .\cookbook\forms.py:350
msgid "Ignore Accent"
msgstr "Ignorēt akcentus"
msgstr ""
#: .\cookbook\forms.py:350
msgid "Partial Match"
@@ -762,8 +745,8 @@ msgid ""
" ."
msgstr ""
"Lūdzu apstipriniet, ka\n"
" <a href=\"mailto:%(email)s\">%(email)s</a> ir e-pasta adrese "
"lietotājam %(user_display)\n"
" <a href=\"mailto:%(email)s\">%(email)s</a> ir lietotāja "
"%(user_display) e-pasta adrese\n"
" ."
#: .\cookbook\templates\account\email_confirm.html:22
@@ -1348,7 +1331,7 @@ msgstr ""
#: .\cookbook\templates\markdown_info.html:57
#: .\cookbook\templates\markdown_info.html:73
msgid "or by leaving a blank line in between."
msgstr "vai atstājot tukšu rindu starp."
msgstr "vai atstājot tukšu rindu starp ."
#: .\cookbook\templates\markdown_info.html:59
#: .\cookbook\templates\markdown_info.html:74
@@ -1460,7 +1443,7 @@ msgstr "Nav Tiesību"
#: .\cookbook\templates\no_groups_info.html:17
msgid "You do not have any groups and therefor cannot use this application."
msgstr "Jūs neesat nevienā grupā un tādēļ nevarat izmantot šo lietotni."
msgstr "Jūs neesat nevienā grupā un tādēļ nevarat izmantot šo lietotni!"
#: .\cookbook\templates\no_groups_info.html:18
#: .\cookbook\templates\no_perm_info.html:15
@@ -1477,7 +1460,7 @@ msgid ""
"You do not have the required permissions to view this page or perform this "
"action."
msgstr ""
"Jums nav nepieciešamo atļauju, lai skatītu šo vietni vai veiktu šo darbību."
"Jums nav nepieciešamo atļauju, lai skatītu šo vietni vai veiktu šo darbību!"
#: .\cookbook\templates\offline.html:6
msgid "Offline"
@@ -1565,16 +1548,6 @@ msgid ""
"html#TEXTSEARCH-PARSING-QUERIES>Postgresql's website.</a>\n"
" "
msgstr ""
" \n"
" Pilnā teksta meklēšanas mēģinājums vienkāršot dotos vārdus, lai "
"tie sakristu ar tipiskajiem variantiem. Piemēram: 'griezt', 'griezšana', "
"'griezums' tiks vienkāršots uz 'griez'.\n"
" Lai kontrolētu meklētāja darbību ievadot vairākus meklējamos "
"vārdus, ir pieejamas vairākas zemāk aprakstītās metodes.\n"
" Pilno tehnisko informāciju par tām var apskatīt <a "
"href=https://www.postgresql.org/docs/current/textsearch-controls.html"
"#TEXTSEARCH-PARSING-QUERIES>Postgresql mājas lapā.</a>\n"
" "
#: .\cookbook\templates\search_info.html:29
msgid ""
@@ -2574,12 +2547,22 @@ msgid "Unable to determine PostgreSQL version."
msgstr ""
#: .\cookbook\views\views.py:317
#, fuzzy
#| msgid ""
#| "\n"
#| " This application is not running with a Postgres database "
#| "backend. This is ok but not recommended as some\n"
#| " features only work with postgres databases.\n"
#| " "
msgid ""
"This application is not running with a Postgres database backend. This is ok "
"but not recommended as some features only work with postgres databases."
msgstr ""
"Šī lietojumprogramma nedarbojas, izmantojot Postgres datubāzi. Tas ir labi, "
"bet nav ieteicams, jo dažas funkcijas darbojas tikai ar Postgres datu bāzēm."
"\n"
" Šī lietojumprogramma nedarbojas, izmantojot Postgres datubāzi. "
"Tas ir labi, bet nav ieteicams, jo dažas\n"
" funkcijas darbojas tikai ar Postgres datu bāzēm.\n"
" "
#: .\cookbook\views\views.py:360
#, fuzzy

View File

@@ -13,8 +13,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"PO-Revision-Date: 2025-07-31 19:14+0000\n"
"Last-Translator: Justin Straver <justin.straver@gmail.com>\n"
"PO-Revision-Date: 2025-02-16 14:58+0000\n"
"Last-Translator: Cots Partier <cots.pastier.34@icloud.com>\n"
"Language-Team: Dutch <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/nl/>\n"
"Language: nl\n"
@@ -46,7 +46,7 @@ msgstr "Voorbereidingstijd in minuten"
#: .\cookbook\forms.py:62
msgid "Waiting time (cooking/baking) in minutes"
msgstr "Wachttijd in minuten (koken en bakken)"
msgstr "Wacht tijd in minuten (koken en bakken)"
#: .\cookbook\forms.py:63 .\cookbook\forms.py:222 .\cookbook\forms.py:246
msgid "Path"

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"PO-Revision-Date: 2025-08-10 11:36+0000\n"
"Last-Translator: Elias Sjögreen <eliassjogreen1@gmail.com>\n"
"PO-Revision-Date: 2025-02-07 08:58+0000\n"
"Last-Translator: Mattias G <mattias.granlund@gmail.com>\n"
"Language-Team: Swedish <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/sv/>\n"
"Language: sv\n"
@@ -641,8 +641,6 @@ msgstr "ID eller enhet att använda för inköpslistan"
#: .\cookbook\serializer.py:1443
msgid "When set to true will delete all food from active shopping lists."
msgstr ""
"Om det här alternativet är aktiverat kommer alla matvaror att raderas från "
"de aktiva inköpslistorna."
#: .\cookbook\tables.py:69 .\cookbook\tables.py:83
#: .\cookbook\templates\generic\delete_template.html:7
@@ -725,13 +723,10 @@ msgid ""
"You currently do not have any e-mail address set up. You should really add "
"an e-mail address so you can receive notifications, reset your password, etc."
msgstr ""
"Just nu har du inga e-post adresser konfigurerade. Du borde verkligen lägga "
"till en e-post adress så att du kan får notiser, återställa ditt lösenord, "
"mm."
#: .\cookbook\templates\account\email.html:64
msgid "Add E-mail Address"
msgstr "Lägg till en e-post adress"
msgstr ""
#: .\cookbook\templates\account\email.html:69
msgid "Add E-mail"
@@ -739,12 +734,12 @@ msgstr "Lägg till email"
#: .\cookbook\templates\account\email.html:79
msgid "Do you really want to remove the selected e-mail address?"
msgstr "Vill du verkligen ta bort den valda e-postadressen?"
msgstr ""
#: .\cookbook\templates\account\email_confirm.html:6
#: .\cookbook\templates\account\email_confirm.html:10
msgid "Confirm E-mail Address"
msgstr "Bekräfta e-postadress"
msgstr ""
#: .\cookbook\templates\account\email_confirm.html:16
#, python-format
@@ -754,10 +749,6 @@ msgid ""
"for user %(user_display)s\n"
" ."
msgstr ""
"Vänligen bekräfra att\n"
" <a href=\"mailto:%(email)s\">%(email)s</a> är e-postadressen för "
"användaren %(user_display)s\n"
" ."
#: .\cookbook\templates\account\email_confirm.html:22
#: .\cookbook\templates\generic\delete_template.html:72
@@ -771,9 +762,6 @@ msgid ""
" <a href=\"%(email_url)s\">issue a new e-mail confirmation "
"request</a>."
msgstr ""
"Denna e-post bekräftelselänk är utgången eller felaktig. Vänligen\n"
" <a href=\"%(email_url)s\">skapa en ny "
"e-postbekräftelsebegäran</a>."
#: .\cookbook\templates\account\login.html:8 .\cookbook\templates\base.html:388
#: .\cookbook\templates\openid\login.html:8
@@ -797,17 +785,19 @@ msgstr "Logga in"
#: .\cookbook\templates\account\password_reset_done.html:33
#: .\cookbook\templates\socialaccount\signup.html:8
#: .\cookbook\templates\socialaccount\signup.html:57
#, fuzzy
#| msgid "Sign In"
msgid "Sign Up"
msgstr "Registrera dig"
msgstr "Logga in"
#: .\cookbook\templates\account\login.html:38
msgid "Lost your password?"
msgstr "Glömt ditt lösenord?"
msgstr ""
#: .\cookbook\templates\account\login.html:39
#: .\cookbook\templates\account\password_reset.html:29
msgid "Reset My Password"
msgstr "Återställ mitt lösenord"
msgstr ""
#: .\cookbook\templates\account\login.html:50
msgid "Social Login"
@@ -834,8 +824,10 @@ msgstr "Är du säker på att du vill logga ut?"
#: .\cookbook\templates\account\password_reset_from_key.html:13
#: .\cookbook\templates\account\password_reset_from_key_done.html:7
#: .\cookbook\templates\account\password_reset_from_key_done.html:13
#, fuzzy
#| msgid "Changes saved!"
msgid "Change Password"
msgstr "Ändra lösenord"
msgstr "Ändringar sparade!"
#: .\cookbook\templates\account\password_change.html:12
#: .\cookbook\templates\account\password_set.html:12
@@ -858,20 +850,18 @@ msgid ""
"Forgotten your password? Enter your e-mail address below, and we'll send you "
"an e-mail allowing you to reset it."
msgstr ""
"Glömt ditt lösenord? Ange din e-postadress nedanför så skickar vi ett "
"återställningmail."
#: .\cookbook\templates\account\password_reset.html:32
#, fuzzy
#| msgid "Password reset is not implemented for the time being!"
msgid "Password reset is disabled on this instance."
msgstr "Återställning av lösenord är avaktiverat på denna instans."
msgstr "Återställning av lösenord har ännu inte lagts till!"
#: .\cookbook\templates\account\password_reset_done.html:25
msgid ""
"We have sent you an e-mail. Please contact us if you do not receive it "
"within a few minutes."
msgstr ""
"Vi har skickat ett e-postmeddelande till dig. Om du inte har fått det inom "
"några minuter, vänligen kontakta oss."
#: .\cookbook\templates\account\password_reset_from_key.html:13
#, fuzzy

View File

@@ -8,22 +8,22 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"PO-Revision-Date: 2025-08-02 07:49+0000\n"
"Last-Translator: TC Kuo <tckuo7@gmail.com>\n"
"Language-Team: Chinese (Traditional Han script) <http://translate.tandoor."
"dev/projects/tandoor/recipes-backend/zh_Hant/>\n"
"PO-Revision-Date: 2024-11-04 10:29+0000\n"
"Last-Translator: Johnny Ip <ip.iohnny@gmail.com>\n"
"Language-Team: Chinese (Traditional) <http://translate.tandoor.dev/projects/"
"tandoor/recipes-backend/zh_Hant/>\n"
"Language: zh_Hant\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 5.8.4\n"
"X-Generator: Weblate 5.6.2\n"
#: .\cookbook\forms.py:45
msgid ""
"Both fields are optional. If none are given the username will be displayed "
"instead"
msgstr "這兩個欄位都是可選的。如果沒有輸入,將顯示用戶名"
msgstr "這兩個字段都是可選的。如果沒有輸入,將顯示用戶名"
#: .\cookbook\forms.py:62 .\cookbook\forms.py:246
msgid "Name"
@@ -31,7 +31,7 @@ msgstr "名字"
#: .\cookbook\forms.py:62 .\cookbook\forms.py:246 .\cookbook\views\lists.py:103
msgid "Keywords"
msgstr "關鍵"
msgstr "關鍵"
#: .\cookbook\forms.py:62
msgid "Preparation time in minutes"
@@ -51,13 +51,14 @@ msgstr "存儲ID"
#: .\cookbook\forms.py:93
msgid "Default"
msgstr "預設"
msgstr "默認"
#: .\cookbook\forms.py:121
msgid ""
"To prevent duplicates recipes with the same name as existing ones are "
"ignored. Check this box to import everything."
msgstr "為防止重複,忽略與現有同名的食譜。選中此框可導入所有內容(包括同名食譜)。"
msgstr ""
"為防止重復,忽略與現有同名的菜譜。選中此框可導入所有內容(包括同名菜譜)。"
#: .\cookbook\forms.py:143
msgid "Add your comment: "
@@ -240,7 +241,7 @@ msgstr "你沒有必要的權限來查看這個頁面!"
#: .\cookbook\helper\permission_helper.py:237
#: .\cookbook\helper\permission_helper.py:252
msgid "You cannot interact with this object as it is not owned by you!"
msgstr "你不能與此對象互,因為它不屬於你!"
msgstr "你不能與此對象互,因為它不屬於你!"
#: .\cookbook\helper\permission_helper.py:402
msgid "You have reached the maximum number of recipes for your space."
@@ -310,16 +311,16 @@ msgstr "在導入過程中發生了一個意外的錯誤。請確認你上傳的
#: .\cookbook\integration\integration.py:217
msgid "The following recipes were ignored because they already existed:"
msgstr "以下譜被忽略了,因為它們已經存在了:"
msgstr "以下譜被忽略了,因為它們已經存在了:"
#: .\cookbook\integration\integration.py:221
#, python-format
msgid "Imported %s recipes."
msgstr "導入了%s譜。"
msgstr "導入了%s譜。"
#: .\cookbook\integration\openeats.py:28
msgid "Recipe source:"
msgstr "譜來源:"
msgstr "譜來源:"
#: .\cookbook\integration\paprika.py:49
msgid "Notes"
@@ -327,7 +328,7 @@ msgstr "說明"
#: .\cookbook\integration\paprika.py:52
msgid "Nutritional Information"
msgstr "營養資訊"
msgstr "營養信息"
#: .\cookbook\integration\paprika.py:56
msgid "Source"
@@ -644,7 +645,7 @@ msgstr "電子郵件地址"
#: .\cookbook\templates\socialaccount\connections.html:10
#: .\cookbook\templates\user_settings.html:8
msgid "Settings"
msgstr "設"
msgstr "設"
#: .\cookbook\templates\account\email.html:13
msgid "Email"
@@ -1877,7 +1878,7 @@ msgstr "你可以被邀請加入現有空間或創建自己的空間。"
#: .\cookbook\templates\space_overview.html:53
msgid "Owner"
msgstr "有者"
msgstr "有者"
#: .\cookbook\templates\space_overview.html:57
msgid "Leave Space"

View File

@@ -592,7 +592,7 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
fields = (
'id', 'name', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at',
'updated_at', 'full_name')
read_only_fields = ('id', 'label', 'numchild', 'numrecipe', 'parent', 'image')
read_only_fields = ('id', 'label', 'numchild', 'parent', 'image')
class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin, OpenDataModelMixin):
@@ -1112,28 +1112,6 @@ class RecipeImportSerializer(SpacedModelSerializer):
fields = '__all__'
class RecipeBatchUpdateSerializer(serializers.Serializer):
recipes = serializers.ListField(child=serializers.IntegerField())
keywords_add = serializers.ListField(child=serializers.IntegerField())
keywords_remove = serializers.ListField(child=serializers.IntegerField())
keywords_set = serializers.ListField(child=serializers.IntegerField())
keywords_remove_all = serializers.BooleanField(default=False)
working_time = serializers.IntegerField(required=False, allow_null=True)
waiting_time = serializers.IntegerField(required=False, allow_null=True)
servings = serializers.IntegerField(required=False, allow_null=True)
servings_text = serializers.CharField(required=False, allow_null=True, allow_blank=True)
private = serializers.BooleanField(required=False, allow_null=True)
shared_add = serializers.ListField(child=serializers.IntegerField())
shared_remove = serializers.ListField(child=serializers.IntegerField())
shared_set = serializers.ListField(child=serializers.IntegerField())
shared_remove_all = serializers.BooleanField(default=False)
show_ingredient_overview = serializers.BooleanField(required=False, allow_null=True)
clear_description = serializers.BooleanField(required=False, allow_null=True)
class CustomFilterSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
shared = UserSerializer(many=True, required=False)
@@ -1502,7 +1480,7 @@ class InviteLinkSerializer(WritableNestedModelSerializer):
fields = (
'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by',
'created_at',)
read_only_fields = ('id', 'uuid', 'used_by', 'created_by', 'created_at',)
read_only_fields = ('id', 'uuid', 'created_by', 'created_at',)
# CORS, REST and Scopes aren't currently working
@@ -1790,7 +1768,6 @@ class RecipeFromSourceResponseSerializer(serializers.Serializer):
class AiImportSerializer(serializers.Serializer):
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)
class ExportRequestSerializer(serializers.Serializer):

View File

@@ -37,20 +37,8 @@
<link id="id_custom_space_css" href="{{ theme_values.custom_theme }}" rel="stylesheet">
{% endif %}
<style>
{% if request.user.userpreference.theme == 'TANDOOR_DARK' %}
/* vueform/multiselect */
/* when append to body is true the multiselects dropdown does not recognize the .v-theme--dark condition and renders a white background otherwise */
.multiselect-dropdown, .multiselect-options, .multiselect-option {
background: #212121 !important;
}
{% endif %}
</style>
</head>
<body>
<div id="app"></div>
{% vite_hmr_client %}
@@ -60,16 +48,16 @@
localStorage.setItem('BASE_PATH', "{% base_path request 'base' %}")
window.addEventListener("load", () => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("{% url 'service_worker' %}", {scope: "{% base_path request 'base' %}" + '/'}).then(function (reg) {
}).catch(function (err) {
console.warn('Error whilst registering service worker', err);
});
} else {
console.warn('service worker not in navigator');
}
});
{#window.addEventListener("load", () => {#}
{# if ("serviceWorker" in navigator) {#}
{# navigator.serviceWorker.register("{% url 'service_worker' %}", {scope: "{% base_path request 'base' %}" + '/'}).then(function (reg) {#}
{# }).catch(function (err) {#}
{# console.warn('Error whilst registering service worker', err);#}
{# });#}
{# } else {#}
{# console.warn('service worker not in navigator');#}
{# }#}
{#});#}
</script>

File diff suppressed because one or more lines are too long

View File

@@ -53,17 +53,6 @@
{% endblocktrans %}
{% endif %}
<h3 class="mt-5">{% trans 'Plugins' %}</h3>
Clone the plugin using git into the <code>recipe/plugin/</code> folder (Docker mount <code>/opt/recipe/recipes/plugins</code> to the host system and clone into it).
<table class="table table-bordered">
{% for p in plugins %}
<tr>
<td><a href="{{ p.github }}">{{ p.name }}</a> <br/>{{ p.base_path }}</td>
<td><a href="{% url 'view_plugin_update' %}?module={{ p.module }}" class="btn btn-primary">Git Pull</a></td>
</tr>
{% endfor %}
</table>
<h4 class="mt-3">{% trans 'Media Serving' %} <span class="badge text-bg-{% if gunicorn_media %}danger{% else %}success{% endif %}">{% if gunicorn_media %}
{% trans 'Warning' %}{% else %}{% trans 'Ok' %}{% endif %}</span></h4>
{% if gunicorn_media %}

View File

@@ -25,6 +25,10 @@ def get_theming_values(request):
space = Space.objects.filter(id=FORCE_THEME_FROM_SPACE).first()
themes = {
UserPreference.BOOTSTRAP: 'themes/bootstrap.min.css',
UserPreference.FLATLY: 'themes/flatly.min.css',
UserPreference.DARKLY: 'themes/darkly.min.css',
UserPreference.SUPERHERO: 'themes/superhero.min.css',
UserPreference.TANDOOR: 'themes/tandoor.min.css',
UserPreference.TANDOOR_DARK: 'themes/tandoor_dark.min.css',
}

View File

@@ -0,0 +1,157 @@
<?xml version="1.0" encoding="utf-8"?><testsuites name="pytest tests"><testsuite name="pytest" errors="4" failures="0" skipped="0" tests="4" time="34.750" timestamp="2025-07-17T12:15:15.274960+02:00" hostname="DESKTOP-RM10LP5"><testcase classname="cookbook.tests.other.test_automations" name="test_never_unit_automation[arg0]" time="22.035"><error message="failed on setup with &quot;TypeError: type 'Factory' is not subscriptable&quot;">args = ()
kwargs = {'request': &lt;SubRequest 'space_1' for &lt;Function test_never_unit_automation[arg0]&gt;&gt;}
k = 'space_1__name'
@functools.wraps(fixture_function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -&gt; T:
for k in set(kwargs.keys()) - function_args:
del kwargs[k]
&gt; return fixture_function(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
..\..\..\venv\Lib\site-packages\pytest_factoryboy\fixturegen.py:88:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
..\..\..\venv\Lib\site-packages\pytest_factoryboy\fixturegen.py:49: in fn
return function(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
request = &lt;SubRequest 'space_1' for &lt;Function test_never_unit_automation[arg0]&gt;&gt;
factory_name = 'space_factory'
def model_fixture(request: SubRequest, factory_name: str) -&gt; object:
"""Model fixture implementation."""
factoryboy_request: FactoryboyRequest = request.getfixturevalue("factoryboy_request")
# Try to evaluate as much post-generation dependencies as possible
factoryboy_request.evaluate(request)
assert request.fixturename # NOTE: satisfy mypy
fixture_name = request.fixturename
prefix = "".join((fixture_name, SEPARATOR))
factory_class: type[Factory[object]] = request.getfixturevalue(factory_name)
# Create model fixture instance
&gt; NewFactory: type[Factory[object]] = cast(type[Factory[object]], type("Factory", (factory_class,), {}))
^^^^^^^^^^^^^^^
E TypeError: type 'Factory' is not subscriptable
..\..\..\venv\Lib\site-packages\pytest_factoryboy\fixture.py:360: TypeError</error></testcase><testcase classname="cookbook.tests.other.test_automations" name="test_never_unit_automation[arg2]" time="22.047"><error message="failed on setup with &quot;TypeError: type 'Factory' is not subscriptable&quot;">args = ()
kwargs = {'request': &lt;SubRequest 'space_1' for &lt;Function test_never_unit_automation[arg2]&gt;&gt;}
k = 'space_1__name'
@functools.wraps(fixture_function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -&gt; T:
for k in set(kwargs.keys()) - function_args:
del kwargs[k]
&gt; return fixture_function(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
..\..\..\venv\Lib\site-packages\pytest_factoryboy\fixturegen.py:88:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
..\..\..\venv\Lib\site-packages\pytest_factoryboy\fixturegen.py:49: in fn
return function(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
request = &lt;SubRequest 'space_1' for &lt;Function test_never_unit_automation[arg2]&gt;&gt;
factory_name = 'space_factory'
def model_fixture(request: SubRequest, factory_name: str) -&gt; object:
"""Model fixture implementation."""
factoryboy_request: FactoryboyRequest = request.getfixturevalue("factoryboy_request")
# Try to evaluate as much post-generation dependencies as possible
factoryboy_request.evaluate(request)
assert request.fixturename # NOTE: satisfy mypy
fixture_name = request.fixturename
prefix = "".join((fixture_name, SEPARATOR))
factory_class: type[Factory[object]] = request.getfixturevalue(factory_name)
# Create model fixture instance
&gt; NewFactory: type[Factory[object]] = cast(type[Factory[object]], type("Factory", (factory_class,), {}))
^^^^^^^^^^^^^^^
E TypeError: type 'Factory' is not subscriptable
..\..\..\venv\Lib\site-packages\pytest_factoryboy\fixture.py:360: TypeError</error></testcase><testcase classname="cookbook.tests.other.test_automations" name="test_never_unit_automation[arg3]" time="22.106"><error message="failed on setup with &quot;TypeError: type 'Factory' is not subscriptable&quot;">args = ()
kwargs = {'request': &lt;SubRequest 'space_1' for &lt;Function test_never_unit_automation[arg3]&gt;&gt;}
k = 'space_1__name'
@functools.wraps(fixture_function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -&gt; T:
for k in set(kwargs.keys()) - function_args:
del kwargs[k]
&gt; return fixture_function(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
..\..\..\venv\Lib\site-packages\pytest_factoryboy\fixturegen.py:88:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
..\..\..\venv\Lib\site-packages\pytest_factoryboy\fixturegen.py:49: in fn
return function(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
request = &lt;SubRequest 'space_1' for &lt;Function test_never_unit_automation[arg3]&gt;&gt;
factory_name = 'space_factory'
def model_fixture(request: SubRequest, factory_name: str) -&gt; object:
"""Model fixture implementation."""
factoryboy_request: FactoryboyRequest = request.getfixturevalue("factoryboy_request")
# Try to evaluate as much post-generation dependencies as possible
factoryboy_request.evaluate(request)
assert request.fixturename # NOTE: satisfy mypy
fixture_name = request.fixturename
prefix = "".join((fixture_name, SEPARATOR))
factory_class: type[Factory[object]] = request.getfixturevalue(factory_name)
# Create model fixture instance
&gt; NewFactory: type[Factory[object]] = cast(type[Factory[object]], type("Factory", (factory_class,), {}))
^^^^^^^^^^^^^^^
E TypeError: type 'Factory' is not subscriptable
..\..\..\venv\Lib\site-packages\pytest_factoryboy\fixture.py:360: TypeError</error></testcase><testcase classname="cookbook.tests.other.test_automations" name="test_never_unit_automation[arg1]" time="22.143"><error message="failed on setup with &quot;TypeError: type 'Factory' is not subscriptable&quot;">args = ()
kwargs = {'request': &lt;SubRequest 'space_1' for &lt;Function test_never_unit_automation[arg1]&gt;&gt;}
k = 'space_1__name'
@functools.wraps(fixture_function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -&gt; T:
for k in set(kwargs.keys()) - function_args:
del kwargs[k]
&gt; return fixture_function(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
..\..\..\venv\Lib\site-packages\pytest_factoryboy\fixturegen.py:88:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
..\..\..\venv\Lib\site-packages\pytest_factoryboy\fixturegen.py:49: in fn
return function(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
request = &lt;SubRequest 'space_1' for &lt;Function test_never_unit_automation[arg1]&gt;&gt;
factory_name = 'space_factory'
def model_fixture(request: SubRequest, factory_name: str) -&gt; object:
"""Model fixture implementation."""
factoryboy_request: FactoryboyRequest = request.getfixturevalue("factoryboy_request")
# Try to evaluate as much post-generation dependencies as possible
factoryboy_request.evaluate(request)
assert request.fixturename # NOTE: satisfy mypy
fixture_name = request.fixturename
prefix = "".join((fixture_name, SEPARATOR))
factory_class: type[Factory[object]] = request.getfixturevalue(factory_name)
# Create model fixture instance
&gt; NewFactory: type[Factory[object]] = cast(type[Factory[object]], type("Factory", (factory_class,), {}))
^^^^^^^^^^^^^^^
E TypeError: type 'Factory' is not subscriptable
..\..\..\venv\Lib\site-packages\pytest_factoryboy\fixture.py:360: TypeError</error></testcase></testsuite></testsuites>

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,3 @@
import pytest
from django.contrib import auth
from django.test import RequestFactory
from django_scopes import scope
@@ -6,11 +5,7 @@ from django_scopes import scope
from cookbook.helper.ingredient_parser import IngredientParser
@pytest.mark.parametrize("arg", [
[True],
[False],
])
def test_ingredient_parser(arg, u1_s1):
def test_ingredient_parser(u1_s1):
expectations = {
"2¼ l Wasser": (2.25, "l", "Wasser", ""),
"3¼l Wasser": (3.25, "l", "Wasser", ""),
@@ -72,8 +67,7 @@ def test_ingredient_parser(arg, u1_s1):
"1 Porreestange(n) , ca. 200 g": (1.0, None, 'Porreestange(n)', 'ca. 200 g'), # leading space before comma
# test for over long food entries to get properly split into the note field
"1 Lorem ipsum dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l Lorem ipsum dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l": (
1.0, 'Lorem', 'ipsum',
'dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l Lorem ipsum dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l'),
1.0, 'Lorem', 'ipsum', 'dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l Lorem ipsum dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l'),
"1 LoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutlLoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutl": (
1.0, None, 'LoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutlLoremipsumdolorsitametconsetetursadipscingeli',
'LoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutlLoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutl'),
@@ -92,7 +86,7 @@ def test_ingredient_parser(arg, u1_s1):
request = RequestFactory()
request.user = user
request.space = space
ingredient_parser = IngredientParser(request, False, ignore_automations=arg[0])
ingredient_parser = IngredientParser(request, False, ignore_automations=True)
count = 0
with scope(space=space):

View File

@@ -42,7 +42,7 @@ def test_theming_function(space_1, u1_s1):
assert get_theming_values(request)['sticky_nav'] == ''
assert get_theming_values(request)['app_name'] == 'Tandoor Recipes'
space_1.space_theme = Space.TANDOOR
space_1.space_theme = Space.BOOTSTRAP
space_1.nav_bg_color = '#000000'
space_1.nav_text_color = UserPreference.DARK
space_1.app_name = 'test_app_name'
@@ -53,7 +53,7 @@ def test_theming_function(space_1, u1_s1):
request.space = space_1
# space settings apply when set
assert get_theming_values(request)['theme'] == static('themes/tandoor.min.css')
assert get_theming_values(request)['theme'] == static('themes/bootstrap.min.css')
assert get_theming_values(request)['nav_bg_color'] == '#000000'
assert get_theming_values(request)['nav_text_class'] == 'navbar-light'
assert get_theming_values(request)['app_name'] == 'test_app_name'

View File

@@ -80,9 +80,8 @@ urlpatterns = [
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('system/', views.system, name='view_system'),
path('plugin/update/', views.plugin_update, name='view_plugin_update'),
path('abuse/<slug:token>', views.report_share_abuse, name='view_report_share_abuse'),
@@ -121,11 +120,15 @@ urlpatterns = [
path('api-token-auth/', CustomAuthToken.as_view()),
path('offline/', views.offline, name='view_offline'),
path('service-worker.js', views.service_worker, name='service_worker'),
#path('service-worker.js', (TemplateView.as_view(template_name="sw.js", content_type='application/javascript')), name='service_worker'),
path('manifest.json', views.web_manifest, name='web_manifest'),
]
if DEBUG:
urlpatterns.append(path('test/', views.test, name='view_test'))
urlpatterns.append(path('test2/', views.test2, name='view_test2'))
# catchall view for new frontend
urlpatterns += [
path('', views.index, name='index'),

View File

@@ -110,7 +110,7 @@ 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
)
from cookbook.version_info import TANDOOR_VERSION
from cookbook.views.import_export import get_integration
@@ -411,7 +411,6 @@ class MergeMixin(ViewSetMixin):
description='Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.',
type=int),
OpenApiParameter(name='tree', description='Return all self and children of {obj} with ID [int].', type=int),
OpenApiParameter(name='root_tree', description='Return all items belonging to the tree of the given {obj} id', type=int),
]),
move=extend_schema(parameters=[
OpenApiParameter(name="parent", description='The ID of the desired parent of the {obj}.', type=OpenApiTypes.INT,
@@ -424,7 +423,6 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
def get_queryset(self):
root = self.request.query_params.get('root', None)
tree = self.request.query_params.get('tree', None)
root_tree = self.request.query_params.get('root_tree', None)
if root:
if root.isnumeric():
@@ -443,23 +441,10 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
self.queryset = self.model.objects.get(id=int(tree)).get_descendants_and_self()
except self.model.DoesNotExist:
self.queryset = self.model.objects.none()
elif root_tree:
if root_tree.isnumeric():
try:
self.queryset = self.model.objects.get(id=int(root_tree)).get_root().get_descendants_and_self()
except self.model.DoesNotExist:
self.queryset = self.model.objects.none()
else:
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())
self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class,
tree=True)
@@ -1374,83 +1359,6 @@ class RecipeViewSet(LoggingMixin, viewsets.ModelViewSet):
return Response(self.serializer_class(qs, many=True).data)
@decorators.action(detail=False, methods=['PUT'], serializer_class=RecipeBatchUpdateSerializer)
def batch_update(self, request):
serializer = self.serializer_class(data=request.data, partial=True)
if serializer.is_valid():
recipes = Recipe.objects.filter(id__in=serializer.validated_data['recipes'], space=self.request.space)
safe_recipe_ids = Recipe.objects.filter(id__in=serializer.validated_data['recipes'], space=self.request.space).values_list('id', flat=True)
if 'keywords_add' in serializer.validated_data:
keyword_relations = []
for r in recipes:
for k in serializer.validated_data['keywords_add']:
keyword_relations.append(Recipe.keywords.through(recipe_id=r.pk, keyword_id=k))
Recipe.keywords.through.objects.bulk_create(keyword_relations, ignore_conflicts=True, unique_fields=('recipe_id', 'keyword_id',))
if 'keywords_remove' in serializer.validated_data:
for k in serializer.validated_data['keywords_remove']:
Recipe.keywords.through.objects.filter(recipe_id__in=safe_recipe_ids, keyword_id=k).delete()
if 'keywords_set' in serializer.validated_data and len(serializer.validated_data['keywords_set']) > 0:
keyword_relations = []
Recipe.keywords.through.objects.filter(recipe_id__in=safe_recipe_ids).delete()
for r in recipes:
for k in serializer.validated_data['keywords_set']:
keyword_relations.append(Recipe.keywords.through(recipe_id=r.pk, keyword_id=k))
Recipe.keywords.through.objects.bulk_create(keyword_relations, ignore_conflicts=True, unique_fields=('recipe_id', 'keyword_id',))
if 'keywords_remove_all' in serializer.validated_data and serializer.validated_data['keywords_remove_all']:
Recipe.keywords.through.objects.filter(recipe_id__in=safe_recipe_ids).delete()
if 'working_time' in serializer.validated_data:
recipes.update(working_time=serializer.validated_data['working_time'])
if 'waiting_time' in serializer.validated_data:
recipes.update(waiting_time=serializer.validated_data['waiting_time'])
if 'servings' in serializer.validated_data:
recipes.update(servings=serializer.validated_data['servings'])
if 'servings_text' in serializer.validated_data:
recipes.update(servings_text=serializer.validated_data['servings_text'])
if 'private' in serializer.validated_data and serializer.validated_data['private'] is not None:
recipes.update(private=serializer.validated_data['private'])
if 'shared_add' in serializer.validated_data:
shared_relation = []
for r in recipes:
for u in serializer.validated_data['shared_add']:
shared_relation.append(Recipe.shared.through(recipe_id=r.pk, user_id=u))
Recipe.shared.through.objects.bulk_create(shared_relation, ignore_conflicts=True, unique_fields=('recipe_id', 'user_id',))
if 'shared_remove' in serializer.validated_data:
for s in serializer.validated_data['shared_remove']:
Recipe.shared.through.objects.filter(recipe_id__in=safe_recipe_ids, user_id=s).delete()
if 'shared_set' in serializer.validated_data and len(serializer.validated_data['shared_set']) > 0:
shared_relation = []
Recipe.shared.through.objects.filter(recipe_id__in=safe_recipe_ids).delete()
for r in recipes:
for u in serializer.validated_data['shared_set']:
shared_relation.append(Recipe.shared.through(recipe_id=r.pk, user_id=u))
Recipe.shared.through.objects.bulk_create(shared_relation, ignore_conflicts=True, unique_fields=('recipe_id', 'user_id',))
if 'shared_remove_all' in serializer.validated_data and serializer.validated_data['shared_remove_all']:
Recipe.shared.through.objects.filter(recipe_id__in=safe_recipe_ids).delete()
if 'clear_description' in serializer.validated_data and serializer.validated_data['clear_description']:
recipes.update(description='')
if 'show_ingredient_overview' in serializer.validated_data and serializer.validated_data['show_ingredient_overview'] is not None:
recipes.update(show_ingredient_overview=serializer.validated_data['show_ingredient_overview'])
return Response({}, 200)
return Response(serializer.errors, 400)
@extend_schema_view(list=extend_schema(
parameters=[OpenApiParameter(name='food_id', description='ID of food to filter for', type=int), ]))
@@ -1746,8 +1654,7 @@ class AutomationViewSet(LoggingMixin, StandardFilterModelViewSet):
@extend_schema_view(list=extend_schema(parameters=[
OpenApiParameter(name='internal_note', description=_('Text field to store data that gets carried over to the UserSpace created from the InviteLink'), type=str),
OpenApiParameter(name='unused', description=_('Only return InviteLinks that have not been used yet.'), type=bool),
OpenApiParameter(name='internal_note', description=_('Text field to store data that gets carried over to the UserSpace created from the InviteLink'), type=str)
]))
class InviteLinkViewSet(LoggingMixin, StandardFilterModelViewSet):
queryset = InviteLink.objects
@@ -1760,10 +1667,6 @@ 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:
self.queryset = self.queryset.filter(used_by=None)
if is_space_owner(self.request.user, self.request.space):
self.queryset = self.queryset.filter(space=self.request.space).all()
return super().get_queryset()
@@ -1886,9 +1789,9 @@ class RecipeUrlImportView(APIView):
return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_200_OK)
tandoor_url = None
if re.match(r'^(.)*/recipe/[0-9]+/\?share=[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', url):
if re.match('^(.)*/recipe/[0-9]+/?share=[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', url):
tandoor_url = url.replace('/recipe/', '/api/recipe/')
elif re.match(r'^(.)*/view/recipe/[0-9]+/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', url):
elif re.match('^(.)*/view/recipe/[0-9]+/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', url):
tandoor_url = (url.replace('/view/recipe/', '/api/recipe/').replace(re.split('/recipe/[0-9]+', url)[1], '') + '?share=' +
re.split('/recipe/[0-9]+', url)[1].replace('/', ''))
if tandoor_url and validate_import_url(tandoor_url):
@@ -1983,12 +1886,6 @@ class AiImportView(APIView):
messages = []
uploaded_file = serializer.validated_data['file']
if serializer.validated_data['recipe_id']:
if recipe := Recipe.objects.filter(id=serializer.validated_data['recipe_id']).first():
if recipe.file_path:
uploaded_file = get_recipe_provider(recipe).get_file(recipe)
if uploaded_file:
base64type = None
try:

View File

@@ -1,6 +1,5 @@
import os
import re
import subprocess
from datetime import datetime, timedelta
from io import StringIO
from uuid import UUID
@@ -14,10 +13,10 @@ from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import Group
from django.contrib.auth.password_validation import validate_password
from django.core.cache import caches
from django.core.exceptions import ValidationError, PermissionDenied, BadRequest
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.db import models
from django.http import HttpResponseRedirect, JsonResponse, HttpResponse
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.templatetags.static import static
from django.urls import reverse, reverse_lazy
@@ -32,7 +31,7 @@ from cookbook.helper.permission_helper import CustomIsGuest, GroupRequiredMixin,
from cookbook.models import InviteLink, ShareLink, Space, UserSpace
from cookbook.templatetags.theming_tags import get_theming_values
from cookbook.version_info import VERSION_INFO
from recipes.settings import PLUGINS, BASE_DIR
from recipes.settings import PLUGINS
def index(request, path=None, resource=None):
@@ -42,7 +41,7 @@ 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 request.user.is_authenticated or re.search(r'/recipe/\d+/', request.path[:512]) and request.GET.get('share'):
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:
return HttpResponseRedirect(reverse('account_login') + '?next=' + request.path)
@@ -135,16 +134,15 @@ def no_perm(request):
return HttpResponseRedirect(reverse('account_login') + '?next=' + request.GET.get('next', '/search/'))
return render(request, 'no_perm_info.html')
def recipe_pdf_viewer(request, pk):
with scopes_disabled():
recipe = get_object_or_404(Recipe, pk=pk)
if share_link_valid(recipe, request.GET.get('share', None)) or (has_group_permission(
request.user, ['guest']) and recipe.space == request.space):
return render(request, 'pdf_viewer.html', {'recipe_id': pk, 'share': request.GET.get('share', None)})
return HttpResponseRedirect(reverse('index'))
def system(request):
if not request.user.is_superuser:
return HttpResponseRedirect(reverse('index'))
@@ -239,12 +237,11 @@ def system(request):
for x in r.zrange('api:space-request-count', 0, 20, withscores=True, desc=True):
s = x[0].decode('utf-8')
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()
space_stats.append(r.zscore(f'api:space-request-count:{d}', s))
api_space_stats.append(space_stats)
space_stats = [Space.objects.get(pk=s).name, x[1]]
for i in range(0, 6):
d = (date.today() - timedelta(days=i)).isoformat()
space_stats.append(r.zscore(f'api:space-request-count:{d}', s))
api_space_stats.append(space_stats)
cache_response = caches['default'].get(f'system_view_test_cache_entry', None)
if not cache_response:
@@ -268,22 +265,6 @@ def system(request):
})
def plugin_update(request):
if not request.user.is_superuser:
raise PermissionDenied
if not 'module' in request.GET:
raise BadRequest
for p in PLUGINS:
if p['module'] == request.GET['module']:
update_response = subprocess.check_output(['git', 'pull'], cwd=p['base_path'])
print(update_response)
return HttpResponseRedirect(reverse('view_system'))
return HttpResponseRedirect(reverse('view_system'))
def setup(request):
with scopes_disabled():
if User.objects.count() > 0 or 'django.contrib.auth.backends.RemoteUserBackend' in settings.AUTHENTICATION_BACKENDS:
@@ -470,13 +451,6 @@ def offline(request):
return render(request, 'offline.html', {})
def service_worker(request):
with open(os.path.join(BASE_DIR, 'cookbook', 'static', 'vue3', 'service-worker.js'), 'rb') as service_worker_file:
response = HttpResponse(content=service_worker_file)
response['Content-Type'] = 'text/javascript'
return response
def test(request):
if not settings.DEBUG:
return HttpResponseRedirect(reverse('index'))
@@ -542,3 +516,4 @@ def get_orphan_files(delete_orphans=False):
orphans = find_orphans()
return [img[1] for img in orphans]

View File

@@ -11,14 +11,34 @@ If you like this application and want it to give back, there are many ways to co
If you know any foreign languages you can:
Improve the translations for any of the existing languages.
See [here](/contribute/translations/) for further information on how to contribute translation to Tandoor.
Add a new language to the long list of existing translations.
- Armenian
- Bulgarian
- Catalan
- Czech
- Danish
- Dutch
- English
- French
- German
- Hungarian
- Italian
- Latvian
- Norwegian
- Polish
- Russian
- Spanish
- Swedish
See [here](/docs/contribute/translations) for further information on how to contribute translation to Tandoor.
## Issues and Feature Requests
The most basic but also crucial way of contributing is reporting issues and commenting on ideas and feature requests
The most basic but also very important way of contributing is reporting issues and commenting on ideas and feature requests
over at [GitHub issues](https://github.com/vabene1111/recipes/issues).
Without feedback, improvement can't happen, so don't hesitate to say what you want to say.
Without feedback improvement can't happen, so don't hesitate to say what you want to say.
## Documentation
@@ -26,12 +46,12 @@ Helping improve the documentation for Tandoor is one of the easiest ways to give
You can write guides on how to install and configure Tandoor expanding our repository of non-standard configuations.
Or you can write how-to guides using some of Tandoor's advanced features such as authentication or automation.
See [here](/contribute/documentation/) for more information on how to add documentation to Tandoor.
See [here](/docs/contribute/documentation) for more information on how to add documentation to Tandoor.
## Contributing Code
For the truly ambitious, you can help write code to fix issues, add additional features, or write your own scripts using
Tandoor's extensive API and share your work with the community.
Before writing any code, please make sure that you review [contribution guidelines](/contribute/guidelines/) and
[VSCode](/contribute/vscode) or [PyCharm](/contribute/pycharm) specific configurations.
Before writing any code, please make sure that you review [contribution guidelines](/docs/contribute/guidelines) and
[VSCode](/docs/contribute/vscode) or [PyCharm](/docs/contribute/pycharm) specific configurations.

View File

@@ -32,10 +32,10 @@ To contribute to the project you are required to use the following packages with
## Testing
Django uses pytest-django to implement a full suite of testing. If you make any functional changes, please implement the appropriate
Django uses pytest-django to implement a full suite of testing. If you make any functional changes, please implment the appropriate
tests.
Tandoor is also actively soliciting contributors willing to setup vue3 testing. If you have knowledge in this area it would be greatly appreciated.
Tandoor is also actively soliciting contribors willing to setup vue3 testing. If you have knowledge in this area it would be greatly appreciated.
## API Client
@@ -44,7 +44,9 @@ Tandoor is also actively soliciting contributors willing to setup vue3 testing.
The OpenAPI Generator is a Java project. You must have the java binary executable available on your PATH for this to work.
Tandoor uses [django-rest-framework](https://www.django-rest-framework.org/) for API implementation. Making contributions that impact the API requires an understanding of
ViewSets and Serializers.
Viewsets and Serializers.
Also double check that your changes are actively reflected in the schema so that client apis are generated accurately.
The API Client is generated automatically from the OpenAPI interface provided by the Django REST framework.
For this [openapi-generator](https://github.com/OpenAPITools/openapi-generator) is used.
@@ -53,9 +55,17 @@ Install it using your desired setup method. (For example, using `npm install @op
### Vue
Generate the schema using the `generate_api_client.py` script in the main directory.
Navigate to `vue/src/utils/openapi`.
Generate the schema using `openapi-generator-cli generate -g typescript-axios -i http://127.0.0.1:8000/openapi/`. (Replace your dev server url if required.)
### Vue3
Navigate to `vue3/src/openapi`.
Generate the schema using `openapi-generator-cli generate -g typescript-fetch -i http://127.0.0.1:8000/openapi/`. (Replace your dev server url if required.)
## Install and Configuration
Instructions for [VSCode](/contribute/vscode)
Instructions for [PyCharm](/contribute/pycharm)
Instructions for [VSCode](/docs/contribute/vscode)
Instructions for [PyCharm](/docs/contribute/pycharm)

View File

@@ -1,5 +1,7 @@
<!-- prettier-ignore-start -->
!!! info "Development Setup"
The dev setup is a little messy as this application combines the best (at least in my opinion) of both Django and Vue.js.
<!-- prettier-ignore-end -->
### Devcontainer Setup
@@ -30,15 +32,17 @@ populated from default values.
### Vue.js
!!! danger "Development Setup"
The vite dev server **must** be started before the django runserver command is run or else django will **not** recognize it and try to fallback to the build files.
<!-- prettier-ignore-start -->
!!! warning "Feature Freeze"
With the exception of bug fixes, no changes will be accepted on the legacy `vue` front-end.
<!-- prettier-ignore-end -->
The frontend is build using [Vue.js](https://vuejs.org/).
Most new frontend pages are build using [Vue.js](https://vuejs.org/).
In order to work on these pages, you will have to install a Javascript package manager of your choice. The following examples use yarn.
1. go to the `vue3` and run `yarn install` to install the dependencies
2. run `yarn serve` to start the dev server that allows hot reloading and easy and quick development
In the `vue` folder run `yarn install` followed by `yarn build` to install and build the legacy front-end.
In the `vue3` folder run `yarn install` followed by `yarn build` to install and build the new front-end.
If you do not wish to work on those pages, but instead want the application to work properly during development, run `yarn build` to build the frontend pages once. After that you
might need to run `python manage.py collectstatic` to setup the static files.
After that you can use `yarn serve` from the `vue3` folder to start the development server, and proceed to test your changes.
If you do not wish to work on those pages, but instead want the application to work properly during development, run `yarn build` to build the frontend pages once.

View File

@@ -16,6 +16,8 @@ Maintained by [Aimo](https://github.com/aimok04/kitshn)
- Website: [https://kitshn.app/](https://kitshn.app/)
- Appstores: [Apple](https://apps.apple.com/us/app/kitshn-for-tandoor/id6740168361), [Android](https://play.google.com/store/apps/details?id=de.kitshn.android)
### Untare (discontinued)
Maintained by [phantomate](https://github.com/phantomate/Untare)

View File

@@ -23,7 +23,6 @@
<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://community.tandoor.dev" target="_blank" rel="noopener noreferrer">Community</a> •
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer">Discord</a>
</p>
@@ -66,13 +65,13 @@ Share some information on how you use Tandoor to help me improve the application
<table>
<tr>
<td><a href="https://community.tandoor.dev">Community</a></td>
<td>Get support, share best practices, discuss feature ideas, and meet other Tandoor users.</td>
<td><a href="https://discord.gg/RhzBrfWgtp">Discord</a></td>
<td>We have a public Discord server that anyone can join. This is where all our developers and contributors hang out and where we make announcements</td>
</tr>
<tr>
<td><a href="https://discord.gg/RhzBrfWgtp">Discord</a></td>
<td>We have a public Discord server that anyone can join. This is where all our developers and contributors hang out and where we make announcements</td>
<td><a href="https://twitter.com/TandoorRecipes">Twitter</a></td>
<td>You can follow our Twitter account to get updates on new features or releases</td>
</tr>
</table>

View File

@@ -1,9 +1,6 @@
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested.
!!! danger "Tandoor 2 Compatibility"
This guide has not been verified/tested for Tandoor 2, which now integrates a nginx service inside the default docker container and exposes its service on port 80 instead of 8080.
These are instructions for pacman based distributions, like ArchLinux. The package is available from the [AUR](https://aur.archlinux.org/packages/tandoor-recipes-git) or from [GitHub](https://github.com/jdecourval/tandoor-recipes-pkgbuild).
## Features

View File

@@ -1,32 +1,28 @@
!!! success "Recommended Installation"
Setting up this application using Docker is recommended. This does not mean that other options are bad, but its the only method
that is officially maintained and gets regularly tested.
Setting up this application using Docker is recommended. This does not mean that other options are bad, just that
support is much easier for this setup.
This guide shows you some basic setups using Docker and docker compose. For configuration options see the [configuration page](https://docs.tandoor.dev/system/configuration/).
It is possible to install this application using many different Docker configurations.
## **Versions**
Please read the instructions on each example carefully and decide if this is the way for you.
There are different versions (tags) released on [Docker Hub](https://hub.docker.com/r/vabene1111/recipes/tags).
## **DockSTARTer**
- **latest** Default image. The one you should use if you don't know that you need anything else.
- **beta** Partially stable version that gets updated every now and then. Expect to have some problems.
- **develop** If you want the most bleeding-edge version with potentially many breaking changes, feel free to use this version (not recommended!).
- **X.Y.Z** each released version has its own image. If you need to revert to an old version or want to make sure you stay on one specific use these tags.
!!! danger "No Downgrading"
There is currently no way to migrate back to an older version as there is no mechanism to downgrade the database.
You could probably do it but I cannot help you with that. Choose wisely if you want to use the unstable images.
That said **beta** should usually be working if you like frequent updates and new stuff.
The main goal of [DockSTARTer](https://dockstarter.com/) is to make it quick and easy to get up and running with Docker.
You may choose to rely on DockSTARTer for various changes to your Docker system or use DockSTARTer as a stepping stone and learn to do more advanced configurations.
Follow the guide for installing DockSTARTer and then run `ds` then select 'Configuration' and 'Select Apps' to get Tandoor up and running quickly and easily.
## **Docker**
The docker image (`vabene1111/recipes`) simply exposes the application on the container's port `80` through the integrated nginx webserver.
The docker image (`vabene1111/recipes`) simply exposes the application on the container's port `8080`.
It can be run and accessed on port 80 using:
```shell
docker run -d \
-v "$(pwd)"/staticfiles:/opt/recipes/staticfiles \
-v "$(pwd)"/mediafiles:/opt/recipes/mediafiles \
-p 80:80 \
-p 80:8080 \
-e SECRET_KEY=YOUR_SECRET_KEY \
-e DB_ENGINE=django.db.backends.postgresql \
-e POSTGRES_HOST=db_recipes \
@@ -38,7 +34,25 @@ docker run -d \
vabene1111/recipes
```
Please make sure to replace the ```SECRET_KEY``` and ```POSTGRES_PASSWORD``` placeholders!
Please make sure, if you run your image this way, to consult
the [.env.template](https://raw.githubusercontent.com/vabene1111/recipes/master/.env.template)
file in the GitHub repository to verify if additional environment variables are required for your setup.
Also, don't forget to replace the placeholders for ```SECRET_KEY``` and ```POSTGRES_PASSWORD```!
## **Versions**
There are different versions (tags) released on [Docker Hub](https://hub.docker.com/r/vabene1111/recipes/tags).
- **latest** Default image. The one you should use if you don't know that you need anything else.
- **beta** Partially stable version that gets updated every now and then. Expect to have some problems.
- **develop** If you want the most bleeding edge version with potentially many breaking changes feel free to use this version (not recommended!).
- **X.Y.Z** each released version has its own image. If you need to revert to an old version or want to make sure you stay on one specific use these tags.
!!! danger "No Downgrading"
There is currently no way to migrate back to an older version as there is no mechanism to downgrade the database.
You could probably do it but I cannot help you with that. Choose wisely if you want to use the unstable images.
That said **beta** should usually be working if you like frequent updates and new stuff.
## **Docker Compose**
@@ -49,7 +63,7 @@ The main, and also recommended, installation option for this application is Dock
```shell
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env
```
3. **Edit it accordingly** (you NEED to set `SECRET_KEY` and `POSTGRES_PASSWORD`), see [configuration page](https://docs.tandoor.dev/system/configuration/).
3. **Edit it accordingly** (you NEED to set `SECRET_KEY` and `POSTGRES_PASSWORD`).
4. Start your container using `docker-compose up -d`.
### **Plain**
@@ -65,6 +79,9 @@ wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/d
{% include "./docker/plain/docker-compose.yml" %}
~~~
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
### **Reverse Proxy**
Most deployments will likely use a reverse proxy.
@@ -88,6 +105,8 @@ wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/d
{% include "./docker/traefik-nginx/docker-compose.yml" %}
~~~
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
#### **jwilder's Nginx-proxy**
@@ -115,25 +134,206 @@ wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/d
{% include "./docker/nginx-proxy/docker-compose.yml" %}
~~~
## **DockSTARTer**
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
The main goal of [DockSTARTer](https://dockstarter.com/) is to make it quick and easy to get up and running with Docker.
You may choose to rely on DockSTARTer for various changes to your Docker system or use DockSTARTer as a stepping stone and learn to do more advanced configurations.
Follow the guide for installing DockSTARTer and then run `ds` then select 'Configuration' and 'Select Apps' to get Tandoor up and running quickly and easily.
#### **Nginx Swag by LinuxServer**
[This container](https://github.com/linuxserver/docker-swag) is an all in one solution created by LinuxServer.io.
It contains templates for popular apps, including Tandoor Recipes, so you don't have to manually configure nginx and discard the template provided in Tandoor repo. Tandoor config is called `recipes.subdomain.conf.sample` which you can adapt for your instance.
If you're running Swag on the default port, you'll just need to change the container name to yours.
If your running Swag on a custom port, some headers must be changed:
- Create a copy of `proxy.conf`
- Replace `proxy_set_header X-Forwarded-Host $host;` and `proxy_set_header Host $host;` to
- `proxy_set_header X-Forwarded-Host $http_host;` and `proxy_set_header Host $http_host;`
- Update `recipes.subdomain.conf` to use the new file
- Restart the linuxserver/swag container and Recipes will work correctly
More information [here](https://github.com/TandoorRecipes/recipes/issues/959#issuecomment-962648627).
In both cases, also make sure to mount `/media/` in your swag container to point to your Tandoor Recipes Media directory.
Please refer to the [appropriate documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup.
For step-by-step instructions to set this up from scratch, see [this example](swag.md).
#### **Pure Nginx**
If you have Nginx installed locally on your host system without using any third party integration like Swag or similar, this is for you.
You can use the Docker-Compose file from [Plain](#plain).
!!!warning "Adjust Docker-Compose file"
Replace `80:80` with `PORT:80` with PORT being your desired outward-facing port.
In the nginx config example below, 8080 is used.
An example configuration with LetsEncrypt to get you started can be seen below.
Please note, that since every setup is different, you might need to adjust some things.
!!!warning "Placeholders"
Don't forget to replace the domain and port.
```nginx
server {
if ($host = recipes.mydomain.tld) { # replace domain
return 301 https://$host$request_uri;
}
server_name recipes.mydomain.tld; # replace domain
listen 80;
return 404;
}
server {
server_name recipes.mydomain.tld; # replace domain
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/recipes.mydomain.tld/fullchain.pem; # replace domain
ssl_certificate_key /etc/letsencrypt/live/recipes.mydomain.tld/privkey.pem; # replace domain
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_set_header Host $http_host; # try $host instead if this doesn't work
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:8080; # replace port
proxy_redirect http://127.0.0.1:8080 https://recipes.domain.tld; # replace port and domain
}
}
```
Tandoor does not support directly serving of images, as explained in the [Nginx vs Gunicorn"](#nginx-vs-gunicorn) section. If you are already using nginx to serve as a reverse proxy, you can configure it to serve images as well.
Add the following directly after the `location /` context:
```
location /media/ {
root /media/;
index index.html index.htm;
}
```
Make sure you also update your `docker-compose.yml` file to mount the `mediafiles` directory. If you are using the [Plain](#plain) deployment, you do not need to make any changes. If you are using nginx to act as a reverse proxy for other apps, it may not be optimal to have `mediafiles` mounted to `/media`. In that case, adjust the directory declarations as needed, utilizing nginx's [`alias`](https://nginx.org/en/docs/http/ngx_http_core_module.html#alias) if needed.
!!!note
DockSTARTer might not be updated for Tandoor 2 configurations
Use `alias` if your mount point directory is not the same as the URL request path. Tandoor media files are requested from `$http_host/media/recipes/xxx.jpg`. This means if you are mounting to a directory that does **NOT** end in `./media`, you will need to use `alias`.
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
#### **Apache**
You can use the Docker-Compose file from [Plain](#plain).
!!!warning "Adjust Docker-Compose file"
Replace `80:80` with `PORT:80` with PORT being your desired outward-facing port.
In the Apache config example below, 8080 is used.
If you use e.g. LetsEncrypt for SSL encryption, you can use the example configuration from [solaris7590](https://github.com/TandoorRecipes/recipes/issues/1312#issuecomment-1020034375) below.
!!!warning "Placeholders"
Don't forget to replace the domain and port.
```apache
<IfModule mod_ssl.c>
<VirtualHost *:80>
ServerAdmin webmaster@mydomain.de # replace domain
ServerName mydomain.de # replace domain
Redirect permanent / https://mydomain.de/ # replace domain
</VirtualHost>
<VirtualHost *:443>
ServerAdmin webmaster@mydomain.de # replace domain
ServerName mydomain.de # replace domain
SSLEngine on
RequestHeader set X-Forwarded-Proto "https"
Header always set Access-Control-Allow-Origin "*"
ProxyPreserveHost On
ProxyRequests Off
ProxyPass / http://localhost:8080/ # replace port
ProxyPassReverse / http://localhost:8080/ # replace port
SSLCertificateFile /etc/letsencrypt/live/mydomain.de/fullchain.pem # replace domain/path
SSLCertificateKeyFile /etc/letsencrypt/live/mydomain.de/privkey.pem # replace domain/path
Include /etc/letsencrypt/options-ssl-apache.conf
ErrorLog ${APACHE_LOG_DIR}/recipes_error.log
CustomLog ${APACHE_LOG_DIR}/recipes_access.log combined
</VirtualHost>
</IfModule>
```
If you're having issues with the example configuration above, you can try [beedaddy](https://github.com/TandoorRecipes/recipes/issues/1312#issuecomment-1015252663)'s example config.
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
#### **Others**
If you use none of the above mentioned reverse proxies or want to use an existing one on your host machine (like a local nginx or Caddy), simply use the [Plain](#plain) setup above and change the outbound port to one of your liking.
An example port config (inside the respective docker-compose.yml) would be: `8123:80` instead of the `80:80` or if you want to be sure, that Tandoor is **just** accessible via your proxy and don't wanna bother with your firewall, then `127.0.0.1:8123:80` is a viable option too.
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
## **Additional Information**
### **Nginx vs Gunicorn**
All examples use an additional `nginx` container to serve mediafiles and act as the forward facing webserver.
This is **technically not required** but **very much recommended**.
I do not 100% understand the deep technical details but the [developers of gunicorn](https://serverfault.com/questions/331256/why-do-i-need-nginx-and-something-like-gunicorn/331263#331263),
the WSGi server that handles the Python execution, explicitly state that it is not recommended to deploy without nginx.
You will also likely not see any decrease in performance or a lot of space used as nginx is a very light container.
!!! info
Even if you run behind a reverse proxy as described above, using an additional nginx container is the recommended option.
If you run a small private deployment and don't care about performance, security and whatever else feel free to run
without a nginx container.
!!! warning
When running without nginx make sure to enable `GUNICORN_MEDIA` in the `.env`. Without it, media files will be uploaded
but not shown on the page.
For additional information please refer to the [0.9.0 Release](https://github.com/vabene1111/recipes/releases?after=0.9.0)
and [Issue 201](https://github.com/vabene1111/recipes/issues/201) where these topics have been discussed.
See also refer to the [official gunicorn docs](https://docs.gunicorn.org/en/stable/deploy.html).
### **Nginx Config**
Starting with Tandoor 2 the Docker container includes a nginx service. Its default configuration is pulled from the [http.d](https://github.com/TandoorRecipes/recipes/tree/develop/http.d) folder
in the repository.
You can setup a volume to link to the ```/opt/recipes/http.d``` folder inside your container to change the configuration. Keep in mind that you will not receive any updates on the configuration
if you manually change it/bind the folder as a volume.
In order to give the user (you) the greatest amount of freedom when choosing how to deploy this application the
webserver is not directly bundled with the Docker image.
This has the downside that it is difficult to supply the configuration to the webserver (e.g. nginx). Up until
version `0.13.0`, this had to be done manually by downloading the nginx config file and placing it in a directory that
was then mounted into the nginx container.
From version `0.13.0`, the config file is supplied using the application image (`vabene1111/recipes`). It is then mounted
to the host system and from there into the nginx container.
This is not really a clean solution, but I could not find any better alternative that provided the same amount of
usability. If you know of any better way, feel free to open an issue.
### **Volumes vs Bind Mounts**
Since I personally prefer to have my data where my `docker-compose.yml` resides, bind mounts are used in the example
configuration files for all user generated data (e.g. Postgresql and media files).
!!!warning
Please note that [there is a difference in functionality](https://docs.docker.com/storage/volumes/)
between the two and you cannot always simply interchange them.
You can move everything to volumes if you prefer it this way, **but you cannot convert the nginx config file to a bind
mount.**
If you do so you will have to manually create the nginx config file and restart the container once after creating it.
### **Required Headers**
@@ -162,13 +362,12 @@ ProxyPassReverse / http://localhost:8080/ # replace port
### **Setup issues on Raspberry Pi**
!!! danger
Tandoor 2 does no longer build images for arm/v7 architectures. You can certainly get Tandoor working there but it has simply been to much effort to maintain these architectures over the past years
to justify the continued support of this mostly deprecated platform.
!!!info
Always wait at least 2-3 minutes after the very first start, since migrations will take some time!
!!!info
In the past there was a special `*-raspi` version of the image. This no longer exists. The normal Tags all support Arm/v7 architectures which should work on all Raspberry Pi's above Version 1 and the first generation Zero.
See [Wikipedia Raspberry Pi specifications](https://en.wikipedia.org/wiki/Raspberry_Pi#Specifications).
If you're having issues with installing Tandoor on your Raspberry Pi or similar device,
follow these instructions:
@@ -181,7 +380,7 @@ follow these instructions:
### Sub Path nginx config
If hosting under a sub-path you might want to change the default nginx config
If hosting under a sub-path you might want to change the default nginx config (which gets mounted through the named volume from the application container into the nginx container)
with the following config.
```nginx
@@ -208,10 +407,3 @@ location /static/ {
}
```
### Tandoor 1 vs Tandoor 2
Tandoor 1 includes gunicorn, a python WSGI server that handles python code well but is not meant to serve mediafiles. Thus, it has always been recommended to set up a nginx webserver
(not just a reverse proxy) in front of Tandoor to handle mediafiles. The gunicorn server by default is exposed on port 8080.
Tandoor 2 now occasionally bundles nginx inside the container and exposes port 80 where mediafiles are handled by nginx and all the other requests are (mostly) passed to gunicorn.
A [GitHub Issue](https://github.com/TandoorRecipes/recipes/issues/3851) has been created to allow for discussions and FAQ's on this issue while this change is fresh. It will later be updated in the docs here if necessary.

View File

@@ -16,11 +16,28 @@ services:
- ./.env
volumes:
- staticfiles:/opt/recipes/staticfiles
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
- nginx_config:/opt/recipes/nginx/conf.d
- ./mediafiles:/opt/recipes/mediafiles
depends_on:
- db_recipes
networks:
- default
nginx_recipes:
image: nginx:mainline-alpine
restart: always
env_file:
- ./.env
depends_on:
- web_recipes
volumes:
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
- nginx_config:/etc/nginx/conf.d:ro
- staticfiles:/static:ro
- ./mediafiles:/media:ro
networks:
- default
- nginx-proxy
networks:
@@ -30,4 +47,5 @@ networks:
name: nginx-proxy
volumes:
nginx_config:
staticfiles:

View File

@@ -14,9 +14,27 @@ services:
- ./.env
volumes:
- staticfiles:/opt/recipes/staticfiles
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
- nginx_config:/opt/recipes/nginx/conf.d
- ./mediafiles:/opt/recipes/mediafiles
depends_on:
- db_recipes
nginx_recipes:
image: nginx:mainline-alpine
restart: always
ports:
- 80:80
env_file:
- ./.env
depends_on:
- web_recipes
volumes:
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
- nginx_config:/etc/nginx/conf.d:ro
- staticfiles:/static:ro
- ./mediafiles:/media:ro
volumes:
nginx_config:
staticfiles:

View File

@@ -16,23 +16,40 @@ services:
- ./.env
volumes:
- staticfiles:/opt/recipes/staticfiles
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
- nginx_config:/opt/recipes/nginx/conf.d
- ./mediafiles:/opt/recipes/mediafiles
depends_on:
- db_recipes
networks:
- default
nginx_recipes:
image: nginx:mainline-alpine
restart: always
env_file:
- ./.env
volumes:
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
- nginx_config:/etc/nginx/conf.d:ro
- staticfiles:/static:ro
- ./mediafiles:/media:ro
labels: # traefik example labels
- "traefik.enable=true"
- "traefik.http.routers.recipes.rule=Host(`recipes.mydomain.com`, `recipes.myotherdomain.com`)"
- "traefik.http.routers.recipes.entrypoints=web_secure" # your https endpoint
- "traefik.http.routers.recipes.tls.certresolver=le_resolver" # your cert resolver
depends_on:
- web_recipes
networks:
- default
- traefik
networks:
default:
traefik: # This is your external traefik network
external: true
volumes:
nginx_config:
staticfiles:

View File

@@ -3,9 +3,6 @@
Many thanks to [alexbelgium](https://github.com/alexbelgium) for making implementing everything required to have
Tandoor run in HA.
!!! danger "Tandoor 2 Compatibility"
This guide has not been verified/tested for Tandoor 2, which now integrates a nginx service inside the default docker container and exposes its service on port 80 instead of 8080.
![Addon version](https://img.shields.io/badge/dynamic/json?label=Version&query=%24.version&url=https%3A%2F%2Fraw.githubusercontent.com%2Falexbelgium%2Fhassio-addons%2Fmaster%2Ftandoor_recipes%2Fconfig.json) ![Last update](https://img.shields.io/badge/dynamic/json?label=Updated&query=%24.last_update&url=https%3A%2F%2Fraw.githubusercontent.com%2Falexbelgium%2Fhassio-addons%2Fmaster%2Ftandoor_recipes%2Fupdater.json) ![aarch64][aarch64-badge] ![amd64][amd64-badge] ![armv7][armv7-badge]
### Introduction

View File

@@ -63,6 +63,7 @@ spec:
source venv/bin/activate
echo "Updating database"
python manage.py migrate
python manage.py collectstatic_js_reverse
python manage.py collectstatic --noinput
echo "Setting media file attributes"
chown -R 65534:65534 /opt/recipes/mediafiles

View File

@@ -1,12 +1,9 @@
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested.
!!! danger "Tandoor 2 Compatibility"
This guide has not been verified/tested for Tandoor 2, which now integrates a nginx service inside the default docker container and exposes its service on port 80 instead of 8080.
## K8s Setup
This is a setup which should be sufficient for production use. Be sure to replace the default secrets! You can find the example files [here](https://github.com/MyDigitalLife/recipes/tree/fix-k8s-documentation/docs/install/k8s) on Github.
This is a setup which should be sufficient for production use. Be sure to replace the default secrets!
## Files

View File

@@ -1,9 +1,6 @@
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested.
!!! danger "Tandoor 2 Compatibility"
This guide has not been verified/tested for Tandoor 2, which now integrates a nginx service inside the default docker container and exposes its service on port 80 instead of 8080.
[KubeSail](https://kubesail.com/) lets you install Tandoor by providing a simple web interface for installing and managing apps. You can connect any server running Kubernetes, or get a pre-configured [PiBox](https://pibox.io).
<!-- A portion of every PiBox sale goes toward supporting Tandoor development. -->

View File

@@ -5,9 +5,6 @@ These instructions are inspired from a standard django/gunicorn/postgresql instr
!!! warning
Make sure to use at least Python 3.10 (although 3.12 is preferred) or higher, and ensure that `pip` is associated with Python 3. Depending on your system configuration, using `python` or `pip` might default to Python 2. Make sure your machine has at least 2048 MB of memory; otherwise, the `yarn build` process may fail with the error: `FATAL ERROR: Reached heap limit - Allocation failed: JavaScript heap out of memory`.
!!! warning
These instructions are **not** regularly reviewed and might be outdated.
## Prerequisites
Setup user: `sudo useradd recipes`

View File

@@ -3,8 +3,6 @@
This page especially contains some setups that might help you if you really want to go down a certain path but none
of the examples are supported (as I simply am not able to give you support for them).
!!! danger "Tandoor 2 Compatibility"
This guide has not been verified/tested for Tandoor 2, which now integrates a nginx service inside the default docker container and exposes its service on port 80 instead of 8080.
## Apache + Traefik + Sub-Path

View File

@@ -1,9 +1,6 @@
!!! danger
Please refer to the [official documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup. This example shows just one setup that may or may not differ from yours in significant ways. This tutorial does not cover security measures, backups, and many other things that you might want to consider.
!!! danger "Tandoor 2 Compatibility"
This guide has not been verified/tested for Tandoor 2, which now integrates a nginx service inside the default docker container and exposes its service on port 80 instead of 8080.
## Prerequisites
- You have a newly spun-up Ubuntu server with docker (pre-)installed.

View File

@@ -1,9 +1,6 @@
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested. Since I cannot test it myself, feedback and improvements are always very welcome.
!!! danger "Tandoor 2 Compatibility"
This guide has not been verified/tested for Tandoor 2, which now integrates a nginx service inside the default docker container and exposes its service on port 80 instead of 8080.
## **Instructions**
Basic guide to setup `vabenee1111/recipes` docker container on Synology NAS.

60
docs/install/traefik.md Normal file
View File

@@ -0,0 +1,60 @@
!!! danger
Please refer to [the official documentation](https://doc.traefik.io/traefik/).
This example just shows something similar to my setup in case you dont understand the official documentation.
You need to create a network called `traefik` using `docker network create traefik`.
## docker-compose.yml
```
version: "3.3"
services:
traefik:
image: "traefik:v2.1"
container_name: "traefik"
ports:
- "443:443"
- "80:80"
- "8080:8080"
volumes:
- "./letsencrypt:/letsencrypt"
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./config:/etc/traefik/"
networks:
default:
external:
name: traefik
```
## traefik.toml
Place this in a directory called `config` as this is mounted into the traefik container (see docer compose).
**Change the email address accordingly**.
```
[api]
insecure=true
[providers.docker]
endpoint = "unix:///var/run/docker.sock"
exposedByDefault = false
network = "traefik"
#[log]
# level = "DEBUG"
[entryPoints]
[entryPoints.web]
address = ":80"
[entryPoints.web_secure]
address = ":443"
[certificatesResolvers.le_resolver.acme]
email = "you_email@mail.com"
storage = "/letsencrypt/acme.json"
tlsChallenge=true
```

View File

@@ -1,9 +1,6 @@
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested.
!!! danger "Tandoor 2 Compatibility"
This guide has not been verified/tested for Tandoor 2, which now integrates a nginx service inside the default docker container and exposes its service on port 80 instead of 8080.
This guide is to assist those installing Tandoor Recipes on Truenas Core using Docker and or Portainer
Docker install instructions adapted from [PhasedLogix IT Services's guide](https://getmethegeek.com/blog/2021-01-07-add-docker-capabilities-to-truenas-core/). Portainer install instructions adopted from the [Portainer Official Documentation](https://docs.portainer.io/start/install-ce/server/docker/linux). Tandoor installation on Portainer provided by users `Szeraax` and `TransatlanticFoe` on Discord (Thank you two!)

View File

@@ -1,9 +1,6 @@
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested.
!!! danger "Tandoor 2 Compatibility"
This guide has not been verified/tested for Tandoor 2, which now integrates a nginx service inside the default docker container and exposes its service on port 80 instead of 8080.
[Unraid](https://unraid.net/) is an operating system that allows you to easily install and setup applications.
Thanks to [CorneliousJD](https://github.com/CorneliousJD) this application can easily be installed using unraid.

View File

@@ -1,10 +1,3 @@
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested. Since I cannot test it myself, feedback and improvements are always very welcome.
!!! danger "Tandoor 2 Compatibility"
This guide has not been verified/tested for Tandoor 2, which now integrates a nginx service inside the default docker container and exposes its service on port 80 instead of 8080.
# Ubuntu Installation on Windows (WSL) and Docker Desktop
Install Docker from https://docs.docker.com/desktop/install/windows-install/

View File

@@ -1,15 +1,15 @@
:root > * {
--md-primary-fg-color: #ddbf86;
--md-accent-fg-color: #b55e4f;
:root {
--md-primary-fg-color: #ffcb76;
--md-accent-fg-color: #FF6F00;
--md-primary-fg-color--light: #ddbf86;
--md-primary-fg-color--light: #ffcb76;
/* not working part, has no effect */
--md-primary-bg-color: #121212;
--md-default-bg-color: #121212;
--md-default-bg-color--light: #f5efea;
--md-default-bg-color--lighter: #f5efea;
--md-default-bg-color--lightest: #f5efea;
--md-primary-bg-color: #272727;
--md-default-bg-color: #272727;
--md-default-bg-color--light: #272727;
--md-default-bg-color--lighter: #272727;
--md-default-bg-color--lightest: #272727;
}
/*

View File

@@ -1,30 +0,0 @@
server {
listen 80;
listen [::]:80 ipv6only=on;
server_name localhost;
client_max_body_size 128M;
# serve media files
location /media {
alias ${MEDIA_ROOT};
add_header Content-Disposition 'attachment; filename="$args"';
}
# serve service worker under main path
location = /service-worker.js {
alias ${STATIC_ROOT}/vue3/service-worker.js;
}
# pass requests for dynamic content to gunicorn
location / {
proxy_set_header Host $http_host;
proxy_pass http://localhost:${TANDOOR_PORT};
error_page 502 /errors/http502.html;
}
location /errors/ {
alias /etc/nginx/conf.d/errorpages/;
internal;
}
}

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Simple HttpErrorPages | MIT License | https://github.com/HttpErrorPages -->
<meta charset="utf-8" /><meta http-equiv="X-UA-Compatible" content="IE=edge" /><meta name="viewport" content="width=device-width, initial-scale=1" />
<title>502 - Webservice currently unavailable</title>
<style type="text/css">/*! normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}a:active,a:hover{outline-width:0}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}template{display:none}[hidden]{display:none}/*! Simple HttpErrorPages | MIT X11 License | https://github.com/AndiDittrich/HttpErrorPages */body,html{width:100%;height:100%;background-color:#21232a}body{color:#fff;text-align:center;text-shadow:0 2px 4px rgba(0,0,0,.5);padding:0;min-height:100%;-webkit-box-shadow:inset 0 0 100px rgba(0,0,0,.8);box-shadow:inset 0 0 100px rgba(0,0,0,.8);display:table;font-family:"Open Sans",Arial,sans-serif}h1{font-family:inherit;font-weight:500;line-height:1.1;color:inherit;font-size:36px}h1 small{font-size:68%;font-weight:400;line-height:1;color:#777}a{text-decoration:none;color:#fff;font-size:inherit;border-bottom:dotted 1px #707070}.lead{color:silver;font-size:21px;line-height:1.4}.cover{display:table-cell;vertical-align:middle;padding:0 20px}footer{position:fixed;width:100%;height:40px;left:0;bottom:0;color:#a0a0a0;font-size:14px}</style>
</head>
<body>
<div class="cover"><h1>Tandoor Recipes is not yet available <small>502</small></h1>
<p class="lead">
Services are still trying to start.<br>
Please allow up to 3 minutes after you started the application on your server.<br><br>
If this status persists, check the application or docker logs for further information.<br>
After checking and trying everything mentioned in the <a href="https://docs.tandoor.dev/" target="_blank">docs</a>, you can request help on the project's <a href="https://github.com/TandoorRecipes/recipes/issues/new?assignees=&amp;labels=setup+issue&amp;template=help_request.yml" target="_blank">GitHub</a> page.
</p>
</div>
</body>
</html>

View File

@@ -13,8 +13,6 @@ theme:
favicon: logo_color.svg
palette:
scheme: slate
primary: custom
accent: custom

View File

@@ -6,16 +6,10 @@ server {
client_max_body_size 128M;
# serve media files
location /media {
alias /opt/recipes/mediafiles;
location /media/ {
alias /media/;
add_header Content-Disposition 'attachment; filename="$args"';
}
# serve service worker under main path
location = /service-worker.js {
alias /opt/recipes/staticfiles/vue3/service-worker.js;
}
# pass requests for dynamic content to gunicorn
location / {
proxy_set_header Host $http_host;

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"@vueuse/core": "^10.9.0"
}
}

View File

@@ -1,19 +0,0 @@
import os
import subprocess
import traceback
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PLUGINS_DIRECTORY = os.path.join(BASE_DIR, 'recipes', 'plugins')
if os.path.isdir(PLUGINS_DIRECTORY):
for d in os.listdir(PLUGINS_DIRECTORY):
if d != '__pycache__':
try:
subprocess.run(['python', 'setup_repo.py'], shell=(os.name == 'nt'), cwd=os.path.join(BASE_DIR, 'recipes', 'plugins', d))
except Exception:
traceback.print_exc()
print(f'ERROR failed to link plugin {d}')
subprocess.run(['npm', 'install', '--global', 'yarn'], shell=(os.name == 'nt'), cwd=os.path.join(BASE_DIR, 'vue3'))
subprocess.run(['yarn', 'install'], shell=(os.name == 'nt'), cwd=os.path.join(BASE_DIR, 'vue3'))
subprocess.run(['yarn', 'build'], shell=(os.name == 'nt'), cwd=os.path.join(BASE_DIR, 'vue3'))

View File

@@ -4,5 +4,4 @@ testpaths = cookbook/tests
python_files = tests.py test_*.py *_tests.py
# uncomment to run coverage reports
addopts = -n auto --cov=. --cov-report=html:docs/reports/coverage --cov-report=xml:docs/reports/coverage/coverage.xml --junitxml=docs/reports/tests/pytest.xml --html=docs/reports/tests/tests.html
# addopts = -n auto --junitxml=docs/reports/tests/pytest.xml --html=docs/reports/tests/tests.html
asyncio_default_fixture_loop_scope = fixture
# addopts = -n auto --junitxml=docs/reports/tests/pytest.xml --html=docs/reports/tests/tests.html

View File

@@ -221,7 +221,10 @@ try:
'module': f'recipes.plugins.{d}',
'base_path': os.path.join(BASE_DIR, 'recipes', 'plugins', d),
'base_url': plugin_class.base_url,
'bundle_name': plugin_class.bundle_name if hasattr(plugin_class, 'bundle_name') else '',
'api_router_name': plugin_class.api_router_name if hasattr(plugin_class, 'api_router_name') else '',
'nav_main': plugin_class.nav_main if hasattr(plugin_class, 'nav_main') else '',
'nav_dropdown': plugin_class.nav_dropdown if hasattr(plugin_class, 'nav_dropdown') else '',
}
PLUGINS.append(plugin_config)
print(f'PLUGIN {d} loaded')
@@ -531,6 +534,28 @@ if REDIS_HOST:
# Vue webpack settings
VUE_DIR = os.path.join(BASE_DIR, 'vue')
WEBPACK_LOADER = {
'DEFAULT': {
'CACHE': not DEBUG,
'BUNDLE_DIR_NAME': 'vue/', # must end with slash
'STATS_FILE': os.path.join(VUE_DIR, 'webpack-stats.json'),
'POLL_INTERVAL': 0.1,
'TIMEOUT': None,
'IGNORE': [r'.+\.hot-update.js', r'.+\.map'],
},
}
for p in PLUGINS:
if p['bundle_name'] != '':
WEBPACK_LOADER[p['bundle_name']] = {
'CACHE': not DEBUG,
'BUNDLE_DIR_NAME': 'vue/', # must end with slash
'STATS_FILE': os.path.join(p["base_path"], 'vue', 'webpack-stats.json'),
'POLL_INTERVAL': 0.1,
'TIMEOUT': None,
'IGNORE': [r'.+\.hot-update.js', r'.+\.map'],
}
DJANGO_VITE = {
"default": {
"dev_mode": False,

View File

@@ -14,7 +14,7 @@ bleach==6.2.0
gunicorn==23.0.0
lxml==5.3.1
Markdown==3.7
Pillow==11.3.0
Pillow==11.1.0
psycopg2-binary==2.9.10
python-dotenv==1.0.0
requests==2.32.4
@@ -26,10 +26,10 @@ pyyaml==6.0.2
uritemplate==4.1.1
beautifulsoup4==4.12.3
microdata==0.8.0
mock==5.2.0
mock==5.1.0
Jinja2==3.1.6
django-allauth[mfa,socialaccount]==65.9.0
recipe-scrapers==15.8.0
recipe-scrapers==15.6.0
django-scopes==2.0.0
django-treebeard==4.7.1
django-cors-headers==4.6.0
@@ -41,9 +41,9 @@ python-ldap==3.4.4
django-auth-ldap==4.6.0
pyppeteer==2.0.0
pytubefix==9.2.2
aiohttp==3.12.15
aiohttp==3.12.14
inflection==0.5.1
redis==6.2.0
redis==5.2.1
hiredis==3.2.1
requests-oauthlib==2.0.0
pyjwt==2.10.1
@@ -55,11 +55,11 @@ litellm==1.64.1
# Development
pytest==8.4.1
pytest-django==4.11.0
pytest-cov===6.2.1
pytest-factoryboy==2.8.1
pytest-cov===6.0.0
pytest-factoryboy==2.8.0
pytest-html==4.1.1
pytest-asyncio==1.1.0
pytest-xdist==3.8.0
pytest-asyncio==0.25.3
pytest-xdist==3.7.0
autopep8==2.3.2
flake8==7.3.0
yapf==0.40.2

View File

@@ -9,11 +9,11 @@
"preview": "vite preview"
},
"dependencies": {
"@types/luxon": "^3.7.1",
"@types/luxon": "^3.6.2",
"@vueform/multiselect": "^2.6.11",
"@vueuse/core": "^13.6.0",
"@vueuse/router": "^13.6.0",
"luxon": "^3.7.1",
"@vueuse/core": "^13.1.0",
"@vueuse/router": "^13.1.0",
"luxon": "^3.6.1",
"mavon-editor": "^3.0.1",
"pinia": "^3.0.2",
"vue": "^3.5.13",
@@ -23,7 +23,7 @@
"vue-simple-calendar": "7.1.0",
"vuedraggable": "^4.1.0",
"@types/sortablejs": "^1.15.8",
"vuetify": "^3.9.3"
"vuetify": "^3.9.0"
},
"devDependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
@@ -35,8 +35,7 @@
"jsdom": "^26.1.0",
"typescript": "^5.8.3",
"vite": "6.3.5",
"vite-plugin-pwa": "^1.0.2",
"workbox-core": "^7.3.0",
"vite-plugin-pwa": "^1.0.1",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0",
"workbox-background-sync": "^7.3.0",

View File

@@ -38,7 +38,7 @@
</v-list-item>
<v-divider></v-divider>
<component :is="item.component" :="item" :key="item.title" v-for="item in useNavigation().getUserNavigation()"></component>
<component :is="item.component" :="item" v-for="item in useNavigation().getUserNavigation()"></component>
</v-list>
</v-menu>
</v-avatar>
@@ -79,7 +79,7 @@
<v-list-item-subtitle>{{ useUserPreferenceStore().activeSpace.name }}</v-list-item-subtitle>
</v-list-item>
<v-divider></v-divider>
<component :is="item.component" :="item" :key="item.title" v-for="item in useNavigation().getNavigationDrawer()"></component>
<component :is="item.component" :="item" v-for="item in useNavigation().NAVIGATION_DRAWER"></component>
<navigation-drawer-context-menu></navigation-drawer-context-menu>
</v-list>
@@ -113,7 +113,7 @@
<v-icon icon="fa-fw fas fa-bars"></v-icon>
<v-bottom-sheet activator="parent" close-on-content-click>
<v-list nav>
<component :is="item.component" :="item" :key="item.title" v-for="item in useNavigation().getBottomNavigation()"></component>
<component :is="item.component" :="item" v-for="item in useNavigation().BOTTOM_NAVIGATION"></component>
</v-list>
</v-bottom-sheet>
</v-btn>
@@ -137,21 +137,15 @@ import MessageListDialog from "@/components/dialogs/MessageListDialog.vue";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import NavigationDrawerContextMenu from "@/components/display/NavigationDrawerContextMenu.vue";
import {useDjangoUrls} from "@/composables/useDjangoUrls";
import {nextTick, onMounted} from "vue";
import {onMounted} from "vue";
import {isSpaceAboveLimit} from "@/utils/logic_utils";
import {useMediaQuery, useTitle} from "@vueuse/core";
import {useMediaQuery} from "@vueuse/core";
import HelpDialog from "@/components/dialogs/HelpDialog.vue";
import {NAVIGATION_DRAWER} from "@/utils/navigation.ts";
import {useNavigation} from "@/composables/useNavigation.ts";
import {useRouter} from "vue-router";
import {useI18n} from "vue-i18n";
const {lgAndUp} = useDisplay()
const {getDjangoUrl} = useDjangoUrls()
const {t} = useI18n()
const title = useTitle()
const router = useRouter()
const isPrintMode = useMediaQuery('print')
@@ -159,19 +153,6 @@ onMounted(() => {
useUserPreferenceStore()
})
/**
* global title update handler, might be overridden by page specific handlers
*/
router.afterEach((to, from) => {
nextTick(() => {
if (to.meta.title) {
title.value = t(to.meta.title)
} else {
title.value = 'Tandoor'
}
})
})
</script>
<style>
@@ -214,7 +195,7 @@ router.afterEach((to, from) => {
}
.cv-day.today {
background-color: rgba(185, 135, 102, 0.2) !important;
background-color: var(--primary) !important;
}
.cv-day.outsideOfMonth {
@@ -228,6 +209,12 @@ router.afterEach((to, from) => {
.d01 .cv-day-number {
background-color: #b98766 !important;
}
/* vueform/multiselect */
.multiselect-dropdown {
background: #212121 !important;
}
}
.v-theme--light {

View File

@@ -8,67 +8,63 @@ import vuetify from "@/vuetify";
import mavonEditor from 'mavon-editor'
import 'mavon-editor/dist/css/index.css'
import 'vite/modulepreload-polyfill';
import {createRulesPlugin} from 'vuetify/labs/rules'
import { createRulesPlugin } from 'vuetify/labs/rules'
import {setupI18n} from "@/i18n";
import MealPlanPage from "@/pages/MealPlanPage.vue";
import {TANDOOR_PLUGINS, TandoorPlugin} from "@/types/Plugins.ts";
import {TandoorPlugin} from "@/types/Plugins.ts";
let routes = [
{path: '/', component: () => import("@/pages/StartPage.vue"), name: 'StartPage' },
{path: '/', component: () => import("@/pages/StartPage.vue"), name: 'StartPage'},
{path: '/search', redirect: {name: 'StartPage'}},
{path: '/test', component: () => import("@/pages/TestPage.vue"), name: 'view_test'},
{path: '/help', component: () => import("@/pages/HelpPage.vue"), name: 'HelpPage', meta: {title: 'Help'}},
{path: '/help', component: () => import("@/pages/HelpPage.vue"), name: 'HelpPage'},
{
path: '/settings', component: () => import("@/pages/SettingsPage.vue"), name: 'SettingsPage', redirect: '/settings/account',
children: [
{path: 'account', component: () => import("@/components/settings/AccountSettings.vue"), name: 'AccountSettings', meta: {title: 'Settings'}},
{path: 'cosmetic', component: () => import("@/components/settings/CosmeticSettings.vue"), name: 'CosmeticSettings', meta: {title: 'Settings'}},
{path: 'shopping', component: () => import("@/components/settings/ShoppingSettings.vue"), name: 'ShoppingSettings', meta: {title: 'Settings'}},
{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'}},
], meta: {title: 'Settings'}
{path: 'account', component: () => import("@/components/settings/AccountSettings.vue"), name: 'AccountSettings'},
{path: 'cosmetic', component: () => import("@/components/settings/CosmeticSettings.vue"), name: 'CosmeticSettings'},
{path: 'shopping', component: () => import("@/components/settings/ShoppingSettings.vue"), name: 'ShoppingSettings'},
{path: 'meal-plan', component: () => import("@/components/settings/MealPlanSettings.vue"), name: 'MealPlanSettings'},
{path: 'search', component: () => import("@/components/settings/SearchSettings.vue"), name: 'SearchSettings'},
{path: 'space', component: () => import("@/components/settings/SpaceSettings.vue"), name: 'SpaceSettings'},
{path: 'space-members', component: () => import("@/components/settings/SpaceMemberSettings.vue"), name: 'SpaceMemberSettings'},
{path: 'user-space', component: () => import("@/components/settings/UserSpaceSettings.vue"), name: 'UserSpaceSettings'},
{path: 'open-data-import', component: () => import("@/components/settings/OpenDataImportSettings.vue"), name: 'OpenDataImportSettings'},
{path: 'export', component: () => import("@/components/settings/ExportDataSettings.vue"), name: 'ExportDataSettings'},
{path: 'api', component: () => import("@/components/settings/ApiSettings.vue"), name: 'ApiSettings'},
]
},
//{path: '/settings/:page', component: SettingsPage, name: 'view_settings_page', props: true},
{path: '/advanced-search', component: () => import("@/pages/SearchPage.vue"), name: 'SearchPage', meta: {title: 'Search'}},
{path: '/shopping', component: () => import("@/pages/ShoppingListPage.vue"), name: 'ShoppingListPage', meta: {title: 'Shopping_list'}},
{path: '/mealplan', component: MealPlanPage, name: 'MealPlanPage', meta: {title: 'Meal_Plan'}},
{path: '/books', component: () => import("@/pages/BooksPage.vue"), name: 'BooksPage', meta: {title: 'Books'}},
{path: '/book/:bookId', component: () => import("@/pages/BookViewPage.vue"), name: 'BookViewPage', props: true, meta: {title: 'Book'}},
{path: '/recipe/import', component: () => import("@/pages/RecipeImportPage.vue"), name: 'RecipeImportPage', meta: {title: 'Import'}},
{path: '/advanced-search', component: () => import("@/pages/SearchPage.vue"), name: 'SearchPage'},
{path: '/shopping', component: () => import("@/pages/ShoppingListPage.vue"), name: 'ShoppingListPage'},
{path: '/mealplan', component: MealPlanPage, name: 'MealPlanPage'},
{path: '/books', component: () => import("@/pages/BooksPage.vue"), name: 'BooksPage'},
{path: '/book/:bookId', component: () => import("@/pages/BookViewPage.vue"), name: 'BookViewPage', props: true},
{path: '/recipe/import', component: () => import("@/pages/RecipeImportPage.vue"), name: 'RecipeImportPage'},
{path: '/recipe/:id', component: () => import("@/pages/RecipeViewPage.vue"), name: 'RecipeViewPage', props: true, meta: {title: 'Recipe'}},
{path: '/recipe/:id', component: () => import("@/pages/RecipeViewPage.vue"), name: 'RecipeViewPage', props: true},
{path: '/view/recipe/:id', redirect: {name: 'RecipeViewPage'}}, // old Tandoor v1 url pattern
{path: '/list/:model?', component: () => import("@/pages/ModelListPage.vue"), props: true, name: 'ModelListPage'},
{path: '/edit/:model/:id?', component: () => import("@/pages/ModelEditPage.vue"), props: true, name: 'ModelEditPage'},
{path: '/database', component: () => import("@/pages/DatabasePage.vue"), props: true, name: 'DatabasePage', meta: {title: 'Database'}},
{path: '/database', component: () => import("@/pages/DatabasePage.vue"), props: true, name: 'DatabasePage'},
{path: '/ingredient-editor', component: () => import("@/pages/IngredientEditorPage.vue"), name: 'IngredientEditorPage', meta: {title: 'Ingredient Editor'}},
{path: '/property-editor', component: () => import("@/pages/PropertyEditorPage.vue"), name: 'PropertyEditorPage', meta: {title: 'Property_Editor'}},
{path: '/ingredient-editor', component: () => import("@/pages/IngredientEditorPage.vue"), name: 'IngredientEditorPage'},
{path: '/property-editor', component: () => import("@/pages/PropertyEditorPage.vue"), name: 'PropertyEditorPage'},
{path: '/space-setup', component: () => import("@/pages/SpaceSetupPage.vue"), name: 'SpaceSetupPage'},
{path: '/:pathMatch(.*)*', component: () => import("@/pages/404Page.vue"), name: '404Page', meta: {title: 'NotFound'}},
]
// load plugin routes into routing table
TANDOOR_PLUGINS.forEach(plugin => {
routes = routes.concat(plugin.routes)
const pluginModules = import.meta.glob('@/plugins/*/plugin.ts', { eager: true })
const tandoorPlugins = [] as TandoorPlugin[]
Object.values(pluginModules).forEach(module => {
tandoorPlugins.push(module.plugin)
routes = routes.concat(module.plugin.routes)
})
const basePath = localStorage.getItem("BASE_PATH")
const pathname = basePath?.startsWith("http") ? new URL(basePath).pathname : undefined
const base = pathname === "/" ? undefined : pathname
const router = createRouter({
history: createWebHistory(base),
history: createWebHistory(),
routes,
})
@@ -78,7 +74,7 @@ const app = createApp(App)
app.use(createPinia())
app.use(vuetify)
app.use(createRulesPlugin({ /* options */}, vuetify.locale))
app.use(createRulesPlugin({ /* options */ }, vuetify.locale))
app.use(router)
app.use(i18n)
app.use(mavonEditor) // TODO only use on pages that need it

View File

@@ -1,98 +0,0 @@
<template>
<v-dialog max-width="600px" :activator="props.activator" v-model="dialog">
<v-card :loading="loading">
<v-closable-card-title
:title="$t('delete_title', {type: $t(genericModel.model.localizationKey)})"
:sub-title="genericModel.getLabel(props.source)"
:icon="genericModel.model.icon"
v-model="dialog"
></v-closable-card-title>
<v-divider></v-divider>
<v-card-text>
{{ $t('BatchDeleteConfirm') }}
<v-list>
<v-list-item border v-for="item in itemsToDelete">
{{ genericModel.getLabel(item) }}
<template #append>
<v-icon icon="fa-solid fa-xmark" color="error" variant="tonal" v-if="failedItems.includes(item)"></v-icon>
<v-icon icon="fa-solid fa-check" color="success" variant="tonal" v-else-if="updatedItems.includes(item)"></v-icon>
<v-icon icon="fa-solid fa-circle-notch fa-spin" variant="tonal" color="info" v-else-if="loading"></v-icon>
<v-btn icon="fa-solid fa-up-right-from-square" :to="{name: 'IngredientEditorPage', query: {food_id: item.id}}"
v-if="genericModel.model.name == 'Food' && failedItems.includes(item)" size="small"></v-btn>
<v-btn icon="fa-solid fa-up-right-from-square" :to="{name: 'IngredientEditorPage', query: {unit_id: item.id}}"
v-if="genericModel.model.name == 'Unit' && failedItems.includes(item)" size="small"></v-btn>
</template>
</v-list-item>
</v-list>
<p class="font-italic text-disabled">{{$t('BatchDeleteHelp')}}</p>
</v-card-text>
<v-card-actions>
<v-btn :disabled="loading" @click="dialog = false">{{ $t('Cancel') }}</v-btn>
<v-btn color="error" @click="deleteAll()" :loading="loading">{{ $t('Delete_All') }}</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} from "@/types/Models.ts";
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
import {useI18n} from "vue-i18n";
const emit = defineEmits(['change'])
const props = defineProps({
model: {type: String as PropType<EditorSupportedModels>, required: true},
items: {type: Array as PropType<Array<EditorSupportedTypes>>, required: true},
activator: {type: String, default: 'parent'},
})
const {t} = useI18n()
const dialog = defineModel<boolean>({default: false})
const loading = ref(false)
const genericModel = getGenericModelFromString(props.model, t)
const itemsToDelete = ref<EditorSupportedTypes[]>([])
const failedItems = ref<EditorSupportedTypes[]>([])
const updatedItems = ref<EditorSupportedTypes[]>([])
watch(dialog, (newValue, oldValue) => {
if(!oldValue && newValue){
itemsToDelete.value = JSON.parse(JSON.stringify(props.items))
}
})
/**
* loop through the items and delete them
*/
function deleteAll() {
let promises: Promise<any>[] = []
loading.value = true
itemsToDelete.value.forEach(item => {
promises.push(genericModel.destroy(item.id!).then((r: any) => {
updatedItems.value.push(item)
}).catch((err: any) => {
failedItems.value.push(item)
}))
})
Promise.allSettled(promises).then(() => {
loading.value = false
emit('change')
})
}
</script>
<style scoped>
</style>

View File

@@ -1,166 +0,0 @@
<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('Recipes'), count: updateItems.length})"
:icon="TRecipe.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('Keywords')" :prepend-icon="TKeyword.icon" variant="plain">
<v-card-text>
<model-select model="Keyword" v-model="batchUpdateRequest.recipeBatchUpdate.keywordsAdd" :object="false" allow-create mode="tags">
<template #prepend>
<v-icon icon="fa-solid fa-add"></v-icon>
</template>
</model-select>
<model-select model="Keyword" v-model="batchUpdateRequest.recipeBatchUpdate.keywordsRemove" :object="false" allow-create mode="tags">
<template #prepend>
<v-icon icon="fa-solid fa-minus"></v-icon>
</template>
</model-select>
<model-select model="Keyword" v-model="batchUpdateRequest.recipeBatchUpdate.keywordsSet" :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('Keywords')})" hide-details v-model="batchUpdateRequest.recipeBatchUpdate.keywordsRemoveAll"></v-checkbox>
</v-card-text>
</v-card>
<v-card :title="$t('Private_Recipe')" :subtitle="$t('Private_Recipe_Help')" prepend-icon="fa-solid fa-eye-slash" variant="plain">
<v-card-text>
<v-select :items="boolUpdateOptions" :label="$t('Private_Recipe')" clearable v-model="batchUpdateRequest.recipeBatchUpdate._private"></v-select>
<model-select model="User" v-model="batchUpdateRequest.recipeBatchUpdate.sharedAdd" :object="false" allow-create mode="tags">
<template #prepend>
<v-icon icon="fa-solid fa-add"></v-icon>
</template>
</model-select>
<model-select model="User" v-model="batchUpdateRequest.recipeBatchUpdate.sharedRemove" :object="false" allow-create mode="tags">
<template #prepend>
<v-icon icon="fa-solid fa-minus"></v-icon>
</template>
</model-select>
<model-select model="User" v-model="batchUpdateRequest.recipeBatchUpdate.sharedSet" :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('Users')})" hide-details v-model="batchUpdateRequest.recipeBatchUpdate.sharedRemoveAll"></v-checkbox>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card :title="$t('Miscellaneous')" prepend-icon="fa-solid fa-list" variant="plain">
<v-card-text>
<v-number-input :label="$t('WorkingTime')" v-model="batchUpdateRequest.recipeBatchUpdate.workingTime" :step="5">
</v-number-input>
<v-number-input :label="$t('WaitingTime')" v-model="batchUpdateRequest.recipeBatchUpdate.waitingTime" :step="5">
</v-number-input>
<v-number-input :label="$t('Serving')" v-model="batchUpdateRequest.recipeBatchUpdate.servings">
</v-number-input>
<v-text-field :label="$t('ServingsText')" v-model="batchUpdateRequest.recipeBatchUpdate.servingsText" @update:model-value="updateServings = true">
<template #append>
<v-checkbox v-model="updateServings" hide-details></v-checkbox>
</template>
</v-text-field>
<v-select :items="boolUpdateOptions" :label="$t('show_ingredient_overview')" clearable v-model="batchUpdateRequest.recipeBatchUpdate.showIngredientOverview"></v-select>
<v-checkbox hide-details :label="$t('DeleteSomething', {item: $t('Description')})" v-model="batchUpdateRequest.recipeBatchUpdate.clearDescription"></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="batchUpdateRecipes()" :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, TKeyword, TRecipe} from "@/types/Models.ts";
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
import {useI18n} from "vue-i18n";
import {ApiApi, ApiRecipeBatchUpdateUpdateRequest, 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<RecipeOverview>>, required: true},
activator: {type: String, default: 'parent'},
})
const {t} = useI18n()
const dialog = defineModel<boolean>({default: false})
const loading = ref(false)
const updateItems = ref([] as RecipeOverview[])
const batchUpdateRequest = ref({recipeBatchUpdate: {servingsText: ''}} as ApiRecipeBatchUpdateUpdateRequest)
const updateServings = ref(false)
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.recipeBatchUpdate.recipes = props.items.flatMap(r => r.id!)
updateItems.value = JSON.parse(JSON.stringify(props.items))
}
})
/**
* perform batch request to update recipes
*/
function batchUpdateRecipes() {
let api = new ApiApi()
loading.value = true
// prevent accidentally clearing the field with extra checkbox
if (!updateServings.value) {
batchUpdateRequest.value.recipeBatchUpdate.servingsText = undefined
}
api.apiRecipeBatchUpdateUpdate(batchUpdateRequest.value).then(r => {
}).catch(err => {
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
}).finally(() => {
emit('change')
loading.value = false
})
}
</script>
<style scoped>
</style>

View File

@@ -1,6 +1,6 @@
<template>
<v-dialog max-width="1200px" activator="parent" v-model="dialog">
<v-dialog height="70vh" activator="parent" v-model="dialog">
<v-card>
<v-closable-card-title v-model="dialog" :title="$t('Help')" icon="fa-solid fa-question">
<template #content>
@@ -13,7 +13,68 @@
</v-closable-card-title>
<v-divider></v-divider>
<v-card-text class="pa-0">
<help-view v-model="drawer"></help-view>
<v-layout style="height: 100%">
<v-navigation-drawer style="height: calc(100% + 0px)" v-model="drawer">
<v-list>
<v-list-item>
<v-text-field density="compact" variant="outlined" class="pt-2 pb-2" :label="$t('Search')" hide-details clearable></v-text-field>
</v-list-item>
<v-divider></v-divider>
<v-list-item link title="Start" @click="window = 'start'"></v-list-item>
<v-list-item link title="Space" @click="window = 'space'"></v-list-item>
</v-list>
</v-navigation-drawer>
<v-main>
<v-container>
<v-window v-model="window">
<v-window-item value="start">
<h2>Welcome to Tandoor 2</h2>
<p class="mt-3">Tandoor is one of the most most powerful recipe management suits available. It has constantly been improved since its first
version in 2018.
This knowledgebase explains all important features and concepts. Explore it to find out how Tandoor can help you improve your daily cooking
routine or search
for specific features to help you understand them.</p>
<p class="mt-3">Some of the most important concepts are Spaces, Recipes, Foods and Units.</p>
<v-alert class="mt-3" border="start" variant="tonal" color="success">
<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.
<br/>
<v-btn class="mt-2" href="https://docs.tandoor.dev/contribute/contribute/" target="_blank" prepend-icon="fa-solid fa-code-branch">
Contribute
</v-btn>
<v-btn class="mt-2 ms-2" href="https://github.com/sponsors/vabene1111" target="_blank" prepend-icon="fa-solid fa-dollar-sign">Sponsor
</v-btn>
</v-alert>
</v-window-item>
<v-window-item value="space">
<p class="mt-3">All your data is stored in a Space where you can invite other people to collaborate on your recipe database. Typcially the members of a space
belong to one family/household/organization.</p>
<p class="mt-3">While everyone can access all recipes by default, Books, Shopping Lists and Mealplans are not shared by default. You can share them with other members of your space
using the settings.
</p>
<p class="mt-3">You can create and be a member of multiple spaces. Switch between them freely using the navigation or space settings. Depending
on the permission configured by the space owner you might not have access to all features of a space.</p>
<p class="mt-3"></p>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-database" class="me-2" :to="{name: 'UserSpaceSettings'}">{{ $t('YourSpaces') }}</v-btn>
<v-btn color="primary" variant="tonal" prepend-icon="$settings" class="me-2" :to="{name: 'SpaceSettings'}">{{ $t('SpaceSettings') }}</v-btn>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-users" class="me-2" :to="{name: 'SpaceMemberSettings'}">{{ $t('Invites') }}</v-btn>
</v-window-item>
</v-window>
</v-container>
</v-main>
</v-layout>
</v-card-text>
</v-card>
</v-dialog>
@@ -24,10 +85,9 @@
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
import {ref} from "vue";
import HelpView from "@/components/display/HelpView.vue";
const dialog = ref(false)
const window = ref('start')
const drawer = ref(true)
</script>

View File

@@ -3,38 +3,26 @@
<v-card :loading="loading">
<v-closable-card-title
:title="$t('merge_title', {type: $t(genericModel.model.localizationKey)})"
:sub-title="sourceNames"
:sub-title="genericModel.getLabel(props.source)"
:icon="genericModel.model.icon"
v-model="dialog"
></v-closable-card-title>
<v-divider></v-divider>
<v-card-text>
{{ $t('merge_selection', {source: sourceNames, type: $t(genericModel.model.localizationKey)}) }}
{{ $t('merge_selection', {source: genericModel.getLabel(props.source), type: $t(genericModel.model.localizationKey)}) }}
<model-select :model="props.model" v-model="target" allow-create></model-select>
<v-row>
<v-col>
<v-list>
<v-list-item border v-for="item in sourceItems">
{{ genericModel.getLabel(item) }}
<template #append>
<v-icon icon="fa-solid fa-xmark" color="error" variant="tonal" v-if="failedItems.includes(item)"></v-icon>
<v-icon icon="fa-solid fa-check" color="success" variant="tonal" v-else-if="updatedItems.includes(item)"></v-icon>
<v-icon icon="fa-solid fa-circle-notch fa-spin" variant="tonal" color="info" v-else-if="loading"></v-icon>
</template>
</v-list-item>
<v-list-item class="text-center">
<v-icon icon="fa-solid fa-arrow-down" class="mt-4 mb-4"></v-icon>
</v-list-item>
<v-list-item class="text-center" border>
<span v-if="!target">?</span>
<span v-else>{{ genericModel.getLabel(target) }}</span>
</v-list-item>
</v-list>
<v-col class="text-center">
<v-card color="warning" variant="tonal">
<v-card-title>{{ genericModel.getLabel(props.source) }}</v-card-title>
</v-card>
<v-icon icon="fa-solid fa-arrow-down" class="mt-4 mb-4"></v-icon>
<v-card color="success" variant="tonal">
<v-card-title v-if="!target">?</v-card-title>
<v-card-title v-else>{{ genericModel.getLabel(target) }}</v-card-title>
</v-card>
</v-col>
</v-row>
@@ -42,7 +30,7 @@
</v-card-text>
<v-card-actions>
<v-btn :disabled="loading" @click="dialog = false">{{ $t('Cancel') }}</v-btn>
<v-btn :disabled="loading">{{ $t('Cancel') }}</v-btn>
<v-btn color="warning" @click="mergeModel()" :loading="loading" :disabled="!target">{{ $t('Merge') }}</v-btn>
</v-card-actions>
</v-card>
@@ -52,7 +40,7 @@
<script setup lang="ts">
import ModelSelect from "@/components/inputs/ModelSelect.vue";
import {computed, PropType, ref, watch} from "vue";
import {PropType, ref} from "vue";
import {EditorSupportedModels, EditorSupportedTypes, getGenericModelFromString} from "@/types/Models";
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
import {useI18n} from "vue-i18n";
@@ -63,7 +51,7 @@ const emit = defineEmits(['change'])
const props = defineProps({
model: {type: String as PropType<EditorSupportedModels>, required: true},
source: {type: Array as PropType<Array<EditorSupportedTypes>>, required: true},
source: {type: {} as PropType<EditorSupportedTypes>, required: true},
activator: {type: String, default: 'parent'},
})
@@ -76,62 +64,41 @@ const automate = ref(false)
const genericModel = getGenericModelFromString(props.model, t)
const target = ref<null | EditorSupportedTypes>(null)
const sourceItems = ref<EditorSupportedTypes[]>([])
const failedItems = ref<EditorSupportedTypes[]>([])
const updatedItems = ref<EditorSupportedTypes[]>([])
watch(dialog, (newValue, oldValue) => {
if (!oldValue && newValue) {
sourceItems.value = JSON.parse(JSON.stringify(props.source))
}
})
/**
* generate comma seperated list of item names that act as the source
*/
const sourceNames = computed(() => {
if (sourceItems.value) {
return sourceItems.value.map(i => genericModel.getLabel(i)).join(', ')
}
return ''
})
/**
* merge source into selected target
*/
function mergeModel() {
let api = new ApiApi()
let promises: Promise<any>[] = []
if (target.value != null) {
loading.value = true
sourceItems.value.forEach(sourceItem => {
promises.push(genericModel.merge(sourceItem, target.value).then(r => {
genericModel.merge(props.source, target.value).then(r => {
useMessageStore().addPreparedMessage(PreparedMessage.UPDATE_SUCCESS)
emit('change', target.value)
updatedItems.value.push(sourceItem)
if (automate.value && target.value != null && Object.hasOwn(sourceItem, 'name') && Object.hasOwn(sourceItem, 'name')) {
let automation = {
name: `${t('Merge')} ${sourceItem.name} -> ${target.value.name}`.substring(0, 128),
param1: sourceItem.name,
param2: target.value.name,
type: genericModel.model.mergeAutomation
} as Automation
promises.push(api.apiAutomationCreate({automation: automation}).catch(err => {
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
}))
}
}).catch(err => {
updatedItems.value.push(sourceItem)
}))
if (automate.value && target.value != null && Object.hasOwn(props.source, 'name') && Object.hasOwn(target.value, 'name')) {
let automation = {
name: `${t('Merge') } ${props.source.name} -> ${target.value.name}`.substring(0,128),
param1: props.source.name,
param2: target.value.name,
type: genericModel.model.mergeAutomation
} as Automation
api.apiAutomationCreate({automation: automation}).catch(err => {
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
}).finally(() => {
loading.value = false
dialog.value = false
})
}
}).catch(err => {
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
}).finally(() => {
if (!automate.value) {
loading.value = false
dialog.value = false
}
})
Promise.allSettled(promises).then(() => {
loading.value = false
emit('change')
})
}
}

View File

@@ -1,24 +1,18 @@
<template>
<v-card class="mt-1 h-100">
<iframe width="100%" height="700px" :src="externalUrl" v-if="isPdf"></iframe>
<v-expansion-panels v-model="panelState">
<v-expansion-panel value="show">
<v-expansion-panel-title>{{ $t('ExternalRecipe') }}</v-expansion-panel-title>
<v-expansion-panel-text>
<v-card class="mt-1 h-100">
<iframe width="100%" height="700px" :src="externalUrl" v-if="isPdf"></iframe>
<v-img :src="externalUrl" v-if="isImage"></v-img>
</v-card>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<v-img :src="externalUrl" v-if="isImage"></v-img>
</v-card>
</template>
<script setup lang="ts">
import {computed, onMounted, PropType, ref} from "vue";
import {computed, PropType} from "vue";
import {Recipe} from "@/openapi";
import {useDjangoUrls} from "@/composables/useDjangoUrls";
import {useUrlSearchParams} from "@vueuse/core";
const props = defineProps({
recipe: {type: {} as PropType<Recipe>, required: true}
})
@@ -26,15 +20,6 @@ const props = defineProps({
const params = useUrlSearchParams('history')
const {getDjangoUrl} = useDjangoUrls()
const panelState = ref('')
onMounted(() => {
// open panel by default if recipe has not been converted to internal yet or if it does not have any steps
if (!props.recipe.internal || props.recipe.steps.length == 0) {
panelState.value = 'show'
}
})
/**
* determines if the file is a PDF based on the path
*/

View File

@@ -1,349 +0,0 @@
<template>
<v-layout style="height: 70vh">
<v-navigation-drawer style="height: calc(100% + 0px)" v-model="drawer">
<v-list>
<!-- <v-list-item>-->
<!-- <v-text-field density="compact" variant="outlined" class="pt-2 pb-2" :label="$t('Search')" hide-details clearable></v-text-field>-->
<!-- </v-list-item>-->
<!-- <v-divider></v-divider>-->
<v-list-item link title="Start" @click="window = 'start'" prepend-icon="fa-solid fa-house"></v-list-item>
<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('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>
<v-list-item link title="Recipe Structure" @click="window = 'recipe_structure'" prepend-icon="fa-solid fa-diagram-project"></v-list-item>
<v-list-item link :title="$t('Properties')" @click="window = 'properties'" prepend-icon="fa-solid fa-database"></v-list-item>
<v-list-item link :title="$t('Search')" @click="window = 'recipe_search'" prepend-icon="$search"></v-list-item>
<v-list-item link :title="$t('SavedSearch')" @click="window = 'search_filter'" prepend-icon="fa-solid fa-sd-card"></v-list-item>
<v-list-item link :title="$t('Books')" @click="window = 'books'" prepend-icon="$books"></v-list-item>
<v-list-item link :title="$t('Shopping')" @click="window = 'shopping'" prepend-icon="$shopping"></v-list-item>
<v-list-item link :title="$t('Meal_Plan')" @click="window = 'meal_plan'" prepend-icon="$mealplan"></v-list-item>
</v-list>
</v-navigation-drawer>
<v-main>
<v-container>
<v-window v-model="window">
<v-window-item value="start">
<h2>Welcome to Tandoor 2</h2>
<p class="mt-3">Tandoor is one of the most most powerful recipe management suits available. It has constantly been improved since its first
version in 2018.
This knowledgebase explains all important features and concepts. Explore it to find out how Tandoor can help you improve your daily cooking
routine or search
for specific features to help you understand them.</p>
<v-btn class="mt-2" color="primary" href="https://tandoor.dev" target="_blank" prepend-icon="fa-solid fa-globe">
Website
</v-btn>
<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-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.
<br/>
<v-btn class="mt-2" color="secondary" href="https://docs.tandoor.dev/contribute/contribute/" target="_blank" prepend-icon="fa-solid fa-code-branch">
Contribute
</v-btn>
<v-btn class="mt-2 ms-2" color="success" href="https://github.com/sponsors/vabene1111" target="_blank" prepend-icon="fa-solid fa-dollar-sign">Sponsor
</v-btn>
</v-alert>
</v-window-item>
<v-window-item value="space">
<p class="mt-3">All your data is stored in a Space where you can invite other people to collaborate on your recipe database. Typcially the members of a space
belong to one family/household/organization.</p>
<p class="mt-3">While everyone can access all recipes by default, Books, Shopping Lists and Mealplans are not shared by default. You can share them with other
members of your space
using the settings.
</p>
<p class="mt-3">You can create and be a member of multiple spaces. Switch between them freely using the navigation or space settings. Depending
on the permission configured by the space owner you might not have access to all features of a space.</p>
<p class="mt-3"></p>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-database" class="me-2" :to="{name: 'UserSpaceSettings'}">{{ $t('YourSpaces') }}</v-btn>
<v-btn color="primary" variant="tonal" prepend-icon="$settings" class="me-2" :to="{name: 'SpaceSettings'}">{{ $t('SpaceSettings') }}</v-btn>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-users" class="me-2" :to="{name: 'SpaceMemberSettings'}">{{ $t('Invites') }}</v-btn>
</v-window-item>
<v-window-item value="recipes">
<p class="mt-3">Recipes are the foundation of your Tandoor space. A Recipe has one or more steps that contain ingredients, instructions and other information.
Ingredients in turn consist of an amount, a unit and a food, allowing recipes to be scaled, nutrition's to be calculated and shopping to be organized.
</p>
<p class="mt-3">Besides manually creating them you can also import them from various different places.
</p>
<p class="mt-3">Recipes, by default, are visible to all members of your space. Setting them to private means only you can see it. After setting it to private you
can manually specify the people who should be able to view the recipe.
You can also create a share link for the recipe to share it with everyone that has access to the link.
</p>
<p class="mt-3"></p>
<v-btn color="primary" variant="tonal" prepend-icon="$create" class="me-2" :to="{name: 'ModelEditPage', params: {model: 'Recipe'}}">{{ $t('Create') }}</v-btn>
<v-btn color="primary" variant="tonal" prepend-icon="$search" class="me-2" :to="{name: 'SearchPage'}">{{ $t('Search') }}</v-btn>
</v-window-item>
<v-window-item value="import">
<p class="mt-3">The Recipe importer is one of the most powerful features of Tandoor and allows you to quickly add recipes in multiple different ways.
</p>
<p class="mt-3">The easiest is to import from a URL. If that is not enough you can also import from an Image or PDF file using AI.
</p>
<p class="mt-3">If you already have an existing Recipe database in another format there is also a good chance Tandoor will have an
importer for that program.
</p>
<p class="mt-3"></p>
<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.
They are also essential for the calculation of Properties.
</p>
<p class="mt-3">Setting a base unit allows you to name your Unit however you want (e.g. grams, g, G, gram) while allowing Tandoor
to automatically convert between the units in the same system (weight/volume, e.g. from g to kg or from cup to pint).
</p>
<p class="mt-3">Additionally you can use custom unit conversion to convert between volume and weight trough the specific density
of a food (e.g. 1 cup of flour = 120 g). These conversions are used to calculate the Properties for a Recipe
and might allow cosmetic display changes later.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-scale-balanced" class="me-2" :to="{name: 'ModelListPage', params: {model: 'Unit'}}">
{{ $t('Unit') }}
</v-btn>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-exchange-alt" class="me-2" :to="{name: 'ModelListPage', params: {model: 'UnitConversion'}}">
{{ $t('Conversion') }}
</v-btn>
</v-window-item>
<v-window-item value="food">
<p class="mt-3">Foods have multiple uses in Tandoor. Their most important task is to be part of recipe ingredients together with an amount and
a unit.
</p>
<p class="mt-3">Using the Food editor you can also add properties to a food or link the food to another recipe or external URL.
</p>
<p class="mt-3">Foods are also used or created when adding entries to the shopping list.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-carrot" class="me-2" :to="{name: 'ModelListPage', params: {model: 'Food'}}">
{{ $t('Food') }}
</v-btn>
</v-window-item>
<v-window-item value="keyword">
<p class="mt-3">Keywords are a very flexible Tool to help you organize your recipe collection.
Keywords can quickly be created when editing a Recipe by just typing into the Keywords field or they can
be created trough the Keyword Editor.
</p>
<p class="mt-3">Typical keywords include meal types (breakfast, lunch, dinner, ...), couise (american, italian, ...) or diet (vegan, vegetarian, ..).
</p>
<p class="mt-3">Tip: Using Emojis in Keywords makes them easy to recognize.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-tags" class="me-2" :to="{name: 'ModelListPage', params: {model: 'Keyword'}}">
{{ $t('Keyword') }}
</v-btn>
</v-window-item>
<v-window-item value="recipe_structure">
<p class="mt-3">A Recipe consists of multiple Steps.
</p>
<p class="mt-3">Each Step has Ingreditens (which are at least a Food but typically consist of amount,
Unit and Food). A Step can also contain instuctions, times, files or link to another Recipe.
</p>
<p class="mt-3">Additionally a Recipe can have Properties, Comments, Keywords and more.
</p>
<!-- TODO diagram -->
</v-window-item>
<v-window-item value="properties">
<p class="mt-3">The Properties system allows you to add additional data to your Foods and Recipes in the respective editors.
Most commonly you would use this to add nutrition facts but the system can also be used to track prices,
dietary points or any other kind of property.
</p>
<p class="mt-3">You first need to create the Property Types that you need (e.g. Carbohydrates, Sugar, Price, Points, ..).
Setting the FDC ID for a Property Type allows Tandoor to connect your custom Property Type to a property in the FDC database.
You can then go to a Food, set its FDC ID and Tandoor can automatically pull the properties you want from the FDC database.
</p>
<p class="mt-3">When adding a Property to the Recipe it will just be statically displayed in the Recipe view.
Adding properties to a Foods will allow Tandoor to calculate the properties for all the Ingredients in a Recipe based
on the Foods and their respective Units and Amounts.
</p>
<p class="mt-3">Food Properties are entered based on a certain amount of food (often 100 g). Unit Conversions allow Tandoor to
calculate the property amount if a Food is given in a different unit (e.g. 1kg or 1 cup).
</p>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-database" class="me-2 mt-2 mb-2" :to="{name: 'ModelListPage', params: {model: 'PropertyType'}}">
{{ $t('Property') }}
</v-btn>
<h3>Editor</h3>
<p class="mt-3">Adding Properties manually to every food can be cumbersome. To make it easier you can import the Community curated
Open Data Database. If that is not enough you can open the Property Editor trough the context menu on your recipe.
</p>
<p class="mt-3">Here you can view all Foods in a Recipe and their respective properties. You can also quickly assign FDC ID's to both
Foods and Property Types and import the data from the FDC Database.
</p>
<h3>View</h3>
<p class="mt-3">
Properties are shown below every recipe as soon as you setup your first Property Types.
A small warning triangle is shown if there are missing values for one of the Foods in the recipe.
</p>
<p class="mt-3">
Clicking on the warning triangle allows you to see the individual property amounts of each food and where Properties
or Unit Conversions are missing.
</p>
</v-window-item>
<v-window-item value="recipe_search">
<p class="mt-3">There are two ways to search for Recipes.
</p>
<p class="mt-3">The global quick search can be opened from any page in Tandoor by pressing the search icon in the top right corner.
Here you can quickly search trough your recipes and open them.
</p>
<p class="mt-3">
If you need a bit more fine tuning for your search you can open the advances search and search for all kinds of different things like keywords,
foods or ratings.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="$search" class="me-2" :to="{name: 'SearchPage', }">
{{ $t('Search') }}
</v-btn>
</v-window-item>
<v-window-item value="search_filter">
<p class="mt-3">Once you have performed an advanced search you can save the search filter to easily retrieve it later.
</p>
<p class="mt-3">
This is done by hitting the save button in the advanced search form. To load a search filter you simply select it from the
selection box and hit the load button.
</p>
<p class="mt-3">
You can use saved search filters to automatically create recipe books.
</p>
</v-window-item>
<v-window-item value="books">
<p class="mt-3">Books are a a way to structure and explore your recipe collection. They are similar to keywords but show you a bit more details when
looking trough them.
</p>
<p class="mt-3">After creating a new Book on the books page you can either add recipes manually or you can add a Saved Search Filter to automatically
load recipes into your book based on pre defined search criteria.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="$books" class="me-2" :to="{name: 'BooksPage', }">
{{ $t('Books') }}
</v-btn>
</v-window-item>
<v-window-item value="shopping">
<p class="mt-3">
You can add inidivitual Foods (including non Food items of course) or whole recipes to your shopping list.
By default only you can see the entries you make, by going to the settings you can share them with other users and they can share them with you.
</p>
<p class="mt-3">
You can assign Supermarket Categories to your Foods, either trough the Food Editor or directly by clicking on a Shopping List Entry, to automatically sort the list
according to the Category Order defined in the Supermarket.
</p>
<p class="mt-3">
Each line in the shopping list can contain multiple entries of the same Food.
By clicking on a line you can open a dialog that allows you to see the details and perform various actions.
</p>
<v-list>
<v-list-item>Postpone: Hide the entry from the shopping list for a certain time (specified in the settings)</v-list-item>
<v-list-item>Ignore: Check this Food of the list and do not add it again when adding a recipe to the shopping list</v-list-item>
<v-list-item>Edit: Open the Food's Editor</v-list-item>
<v-list-item>Delete all: Delete all entries associated with this line.</v-list-item>
</v-list>
<p class="mt-3">
The Shopping list automatically syncronizes when multiple people have it open so you can shop with multiple devices.
</p>
<p class="mt-3">
Trough the menu you can also configure which information you want to be displayed or how the list should be sorted.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="$shopping" class="me-2" :to="{name: 'ShoppingListPage', }">
{{ $t('Shopping') }}
</v-btn>
<v-btn color="primary" variant="tonal" prepend-icon="$settings" class="me-2" :to="{name: 'ShoppingSettings', }">
{{ $t('Settings') }}
</v-btn>
</v-window-item>
<v-window-item value="meal_plan">
<p class="mt-3">
To plan what you want to eat you can create a Meal Plan. Each Meal Plan consists of at least a title or a recipe, a date and a Meal Type.
Meal Plan entries a private by default and can either be shared individually or using a preset in the meal plan settings.
</p>
<p class="mt-3">
When selecting a Recipe in a Meal Plan you can automatically add its ingredients to the shopping list. You can also manually add more entries trough the
shopping tab in the Meal Plan editor. When deleting a Meal Plan all Shopping List Entries associated with that Meal Plan are deleted as well. When changing the
number of servings in a Meal Plan the Servings of the connected Recipe in the Shopping list are automatically changed as well.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="$mealplan" class="me-2 mt-2" :to="{name: 'MealPlanPage', }">
{{ $t('Meal_Plan') }}
</v-btn>
<h3 class="mt-3">{{ $t('Meal_Type') }}</h3>
<p class="mt-3">
Meal Types allow you to categorize the different Meal Plan Entries.
</p>
<p class="mt-3">
You can also define a time that is used for sorting and calendar integration.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-utensils" class="me-2 mt-2" :to="{name: 'ModelListPage', params: {model: 'MealType'}}">
{{ $t('Meal_Type') }}
</v-btn>
</v-window-item>
</v-window>
</v-container>
</v-main>
</v-layout>
</template>
<script setup lang="ts">
import {ref} from "vue";
const drawer = defineModel()
const window = ref('start')
</script>
<style scoped>
</style>

View File

@@ -1,30 +1,31 @@
<template>
<!-- <v-row justify="space-between">-->
<!-- <v-col>-->
<!-- <h2><i class="fas fa-calendar-week fa-fw"></i> Meal Plans</h2>-->
<!-- </v-col>-->
<!-- </v-row>-->
<!-- <v-row justify="space-between">-->
<!-- <v-col>-->
<!-- <h2><i class="fas fa-calendar-week fa-fw"></i> Meal Plans</h2>-->
<!-- </v-col>-->
<!-- </v-row>-->
<v-row class="mt-0" v-if="mealPlanWindows.length > 0">
<v-col>
<v-window v-model="currentWindowIndex">
<v-window-item v-for="(w, i) in mealPlanWindows" :value="i" class="pt-1 pb-1">
<v-window show-arrows>
<v-window-item v-for="w in mealPlanWindows" class="pt-1 pb-1">
<v-row>
<v-col v-for="mealPlanGridItem in w">
<v-list density="compact" class="pt-0 pb-0">
<v-list-item class="text-center">
<div class="d-flex ">
<div class="flex-col align-self-start">
<v-btn @click="currentWindowIndex--" v-if="currentWindowIndex != 0" icon="fa-solid fa-chevron-left" size="small"></v-btn>
</div>
<div class="flex-col flex-grow-1 mt-auto mb-auto">
<v-list-item>
<div class="d-flex justify-space-between">
<div class="align-self-center">
{{ mealPlanGridItem.date_label }}
</div>
<div class="flex-col align-self-end">
<v-btn @click="currentWindowIndex++" v-if="currentWindowIndex + 1 < mealPlanWindows.length" icon="fa-solid fa-chevron-right"
size="small"></v-btn>
<div class="align-self-center">
<v-btn variant="flat" icon="">
<i class="fas fa-plus"></i>
<model-edit-dialog model="MealPlan" :item-defaults="{fromDate: mealPlanGridItem.date.toJSDate()}" :close-after-create="false" :close-after-save="false"></model-edit-dialog>
</v-btn>
</div>
</div>
</v-list-item>
<v-divider v-if="mealPlanGridItem.plan_entries.length > 0"></v-divider>
<v-list-item v-for="p in mealPlanGridItem.plan_entries" :key="p.id" @click="clickMealPlan(p)" link>
@@ -46,7 +47,7 @@
<v-menu activator="parent">
<v-list>
<v-list-item prepend-icon="$edit" link>
{{ $t('Edit') }}
{{$t('Edit')}}
<model-edit-dialog model="MealPlan" :item="p"></model-edit-dialog>
</v-list-item>
</v-list>
@@ -54,11 +55,7 @@
</v-btn>
</template>
</v-list-item>
<v-list-item class="text-center cursor-pointer" variant="tonal">
<model-edit-dialog model="MealPlan" :item-defaults="{fromDate: mealPlanGridItem.date.toJSDate()}" :close-after-create="false"
:close-after-save="false"></model-edit-dialog>
<v-icon icon="$create" size="small"></v-icon>
</v-list-item>
</v-list>
</v-col>
</v-row>
@@ -82,9 +79,7 @@ import {useRouter} from "vue-router";
const router = useRouter()
const {name} = useDisplay()
const loading = ref(false)
const currentWindowIndex = ref(0)
let numberOfCols = computed(() => {
return homePageCols(name.value)
@@ -105,12 +100,7 @@ const meal_plan_grid = computed(() => {
grid.push({
date: grid_day_date,
create_default_date: grid_day_date.toISODate(), // improve meal plan edit modal to do formatting itself and accept dates
date_label: grid_day_date.toLocaleString({
weekday: 'short',
month: '2-digit',
day: '2-digit',
year: '2-digit',
}),
date_label: grid_day_date.toLocaleString(DateTime.DATE_MED),
plan_entries: useMealPlanStore().planList.filter((m: MealPlan) => ((DateTime.fromJSDate(m.fromDate).startOf('day') <= grid_day_date.startOf('day')) && (DateTime.fromJSDate((m.toDate != undefined) ? m.toDate : m.fromDate).startOf('day') >= grid_day_date.startOf('day')))),
} as MealPlanGridItem)
}
@@ -144,9 +134,9 @@ onMounted(() => {
})
})
function clickMealPlan(plan: MealPlan) {
if (plan.recipe) {
router.push({name: 'RecipeViewPage', params: {id: plan.recipe.id}})
function clickMealPlan(plan: MealPlan){
if(plan.recipe){
router.push( {name: 'RecipeViewPage', params: {id: plan.recipe.id}})
}
}

View File

@@ -2,9 +2,7 @@
<div v-if="props.keywords">
<slot name="prepend"></slot>
<v-chip class="me-1 mb-1" :label="props.label" :color="props.color" :size="props.size" :variant="props.variant" v-for="k in keywords"
:to="useUserPreferenceStore().isAuthenticated ? {name: 'SearchPage', query: {keywords: k.id}} : undefined"> {{ k.label }}
</v-chip>
<v-chip class="me-1 mb-1" :label="props.label" :color="props.color" :size="props.size" :variant="props.variant" v-for="k in keywords"> {{ k.label }}</v-chip>
<slot name="append"></slot>
</div>
@@ -15,7 +13,6 @@
import {Keyword, KeywordLabel} from "@/openapi";
import {computed, PropType} from "vue";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
const props = defineProps({
keywords: Array as PropType<Array<Keyword> | Array<KeywordLabel> | undefined>,
@@ -24,11 +21,11 @@ const props = defineProps({
variant: {type: String as PropType<NonNullable<"tonal" | "flat" | "text" | "elevated" | "outlined" | "plain"> | undefined>, default: 'tonal'},
label: {type: Boolean, default: true},
// maximum number of keywords, 0 for all
maxKeywords: {type: Number, default: 0},
maxKeywords : {type: Number, default: 0},
})
const keywords = computed(() => {
if (props.maxKeywords > 0) {
if (props.maxKeywords > 0){
return props.keywords?.slice(0, props.maxKeywords)
} else {
return props.keywords

View File

@@ -1,5 +1,5 @@
<template>
<v-card class="mt-2" v-if="hasFoodProperties || hasRecipeProperties">
<v-card class="mt-2">
<v-card-title>
<v-icon icon="$properties"></v-icon>
{{ $t('Properties') }}
@@ -22,8 +22,8 @@
<tbody>
<tr v-for="p in propertyList" :key="p.id">
<td>{{ p.name }}</td>
<td>{{ $n(roundDecimals(p.propertyAmountPerServing)) }} {{ p.unit }}</td>
<td>{{ $n(roundDecimals(p.propertyAmountTotal)) }} {{ p.unit }}</td>
<td>{{ $n(p.propertyAmountPerServing) }} {{ p.unit }}</td>
<td>{{ $n(p.propertyAmountTotal) }} {{ p.unit }}</td>
<td v-if="sourceSelectedToShow == 'food'">
<v-btn @click="dialogProperty = p; dialog = true" variant="plain" color="warning" icon="fa-solid fa-triangle-exclamation" size="small" class="d-print-none"
v-if="p.missingValue"></v-btn>
@@ -36,7 +36,7 @@
</v-card>
<v-dialog max-width="900px" v-model="dialog">
<v-card v-if="dialogProperty" :loading="loading">
<v-card v-if="dialogProperty">
<v-closable-card-title :title="`${dialogProperty.propertyAmountTotal} ${dialogProperty.unit} ${dialogProperty.name}`" :sub-title="$t('total')" icon="$properties"
v-model="dialog"></v-closable-card-title>
<v-card-text>
@@ -60,11 +60,8 @@
<model-edit-dialog model="UnitConversion" @create="refreshRecipe()"
:item-defaults="{baseAmount: 1, baseUnit: fv.missing_conversion.base_unit, convertedUnit: fv.missing_conversion.converted_unit, food: fv.food}"></model-edit-dialog>
</v-chip>
<v-chip color="warning" prepend-icon="$edit" class="cursor-pointer" :to="{name: 'ModelEditPage', params: {model: 'Recipe', id: recipe.id}}" v-else-if="fv.missing_unit">
{{ $t('NoUnit') }}
</v-chip>
<v-chip color="error" prepend-icon="$edit" class="cursor-pointer" v-else>
{{ $t('MissingProperties') }}
{{ $t('Edit') }}
<model-edit-dialog model="Food" :item-id="fv.food.id" @update:model-value="refreshRecipe()"></model-edit-dialog>
</v-chip>
</template>
@@ -86,7 +83,6 @@ import {ApiApi, PropertyType, Recipe} from "@/openapi";
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
import {roundDecimals} from "@/utils/number_utils.ts";
type PropertyWrapper = {
id: number,
@@ -186,8 +182,6 @@ const sourceSelectedToShow = ref<'recipe' | 'food'>("food")
const dialog = ref(false)
const dialogProperty = ref<undefined | PropertyWrapper>(undefined)
const loading = ref(false)
onMounted(() => {
if (!hasFoodProperties) {
sourceSelectedToShow.value = "recipe"
@@ -199,7 +193,6 @@ onMounted(() => {
*/
function refreshRecipe() {
let api = new ApiApi()
loading.value = true
api.apiRecipeRetrieve({id: recipe.value.id!}).then(r => {
recipe.value = r
@@ -212,8 +205,6 @@ function refreshRecipe() {
}
})
}
loading.value = false
})
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)

View File

@@ -9,7 +9,7 @@
<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-rating density="comfortable" size="x-small" color="tandoor" class="float-right" v-model="c.rating"></v-rating>
</v-list-item-title>
{{ c.comment }}
@@ -29,7 +29,7 @@
</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-card-text>
<v-textarea :label="$t('Comment')" rows="2" v-model="newCookLog.comment"></v-textarea>
<v-row de>
@@ -62,7 +62,6 @@ import {ApiApi, CookLog, Recipe} from "@/openapi";
import {DateTime} from "luxon";
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
import {VDateInput} from 'vuetify/labs/VDateInput'
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
const props = defineProps({
recipe: {

View File

@@ -1,26 +1,26 @@
<template>
<template v-if="!props.loading">
<router-link :to="{name: 'RecipeViewPage', params: {id: props.recipe.id}}" :target="linkTarget">
<router-link :to="{name: 'RecipeViewPage', params: {id: props.recipe.id}}">
<recipe-image :style="{height: props.height}" :recipe="props.recipe" rounded="lg" class="mr-3 ml-3">
</recipe-image>
</router-link>
<div class="ml-3">
<div class="d-flex ">
<div class="flex-grow-1 cursor-pointer" @click="openRecipe()">
<div class="flex-grow-1 cursor-pointer" @click="router.push({name: 'RecipeViewPage', params: {id: props.recipe.id}})">
<p class="font-weight-bold mt-2">{{ props.recipe.name }}</p>
</div>
<div class="mt-1">
<!-- <v-btn icon="fas fa-ellipsis-v" size="small" variant="plain"></v-btn>-->
<recipe-context-menu :recipe="props.recipe" size="small" v-if="props.showMenu"></recipe-context-menu>
<recipe-context-menu :recipe="props.recipe" size="small"></recipe-context-menu>
</div>
</div>
<!-- <p class="text-disabled">{{ props.recipe.createdBy.displayName}}</p>-->
<keywords-component variant="outlined" :keywords="props.recipe.keywords" :max-keywords="3" v-if="props.showKeywords">
<keywords-component variant="outlined" :keywords="props.recipe.keywords" :max-keywords="3">
<template #prepend>
<v-chip class="mb-1 me-1" size="x-small" label variant="outlined" color="info"
v-if="props.recipe.internal == false">
v-if="!props.recipe.internal">
{{ $t('External') }}
</v-chip>
<v-chip class="mb-1 me-1" size="x-small" prepend-icon="far fa-clock" label variant="outlined"
@@ -32,7 +32,7 @@
</div>
<v-card :to="{name: 'RecipeViewPage', params: {id: props.recipe.id}}" :style="{'height': props.height}" v-if="false">
<v-card :to="`/recipe/${props.recipe.id}`" :style="{'height': props.height}" v-if="false">
<v-tooltip
class="align-center justify-center"
location="top center" origin="overlap"
@@ -104,27 +104,13 @@ import {useRouter} from "vue-router";
const props = defineProps({
recipe: {type: {} as PropType<Recipe | RecipeOverview>, required: true,},
loading: {type: Boolean, required: false},
showKeywords: {type: Boolean, default: true, required: false},
show_keywords: {type: Boolean, required: false},
show_description: {type: Boolean, required: false},
height: {type: String, required: false, default: '15vh'},
linkTarget: {type: String, required: false, default: ''},
showMenu: {type: Boolean, default: true, required: false}
})
const router = useRouter()
/**
* open the recipe either in the same tab or in a new tab depending on the link target prop
*/
function openRecipe() {
if (props.linkTarget != '') {
const routeData = router.resolve({name: 'RecipeViewPage', params: {id: props.recipe.id}});
window.open(routeData.href, props.linkTarget);
} else {
router.push({name: 'RecipeViewPage', params: {id: props.recipe.id}})
}
}
</script>
<style scoped>

View File

@@ -12,12 +12,12 @@
<template class="d-block d-lg-none">
<!-- mobile layout -->
<v-card class="rounded-0">
<recipe-image
max-height="25vh"
:recipe="recipe"
v-if="recipe.image != undefined">
v-if="recipe.internal"
>
</recipe-image>
<v-card>
@@ -27,47 +27,47 @@
</span>
<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>
<v-rating v-model="recipe.rating" size="x-small" v-if="recipe.rating" half-increments readonly></v-rating>
<keywords-component variant="flat" class="ms-1 mb-2" :keywords="recipe.keywords"></keywords-component>
<v-sheet class="ps-2 text-disabled">
{{ recipe.description }}
</v-sheet>
</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-container>
<v-row class="text-center text-body-2">
<v-col class="pt-1 pb-1">
<i class="fas fa-cogs fa-fw mr-1"></i> {{ recipe.workingTime }} min<br/>
<div class="text-grey">{{ $t('WorkingTime') }}</div>
</v-col>
<v-col class="pt-1 pb-1">
<div><i class="fas fa-hourglass-half fa-fw mr-1"></i> {{ recipe.waitingTime }} min</div>
<div class="text-grey">{{ $t('WaitingTime') }}</div>
</v-col>
<v-col class="pt-1 pb-1">
<template v-if="recipe.internal">
<v-card class="mt-1">
<v-container>
<v-row class="text-center text-body-2">
<v-col class="pt-1 pb-1">
<i class="fas fa-cogs fa-fw mr-1"></i> {{ recipe.workingTime }} min<br/>
<div class="text-grey">{{ $t('WorkingTime') }}</div>
</v-col>
<v-col class="pt-1 pb-1">
<div><i class="fas fa-hourglass-half fa-fw mr-1"></i> {{ recipe.waitingTime }} min</div>
<div class="text-grey">{{ $t('WaitingTime') }}</div>
</v-col>
<v-col class="pt-1 pb-1">
<div class="cursor-pointer">
<i class="fas fa-sort-numeric-up fa-fw mr-1"></i> {{ servings }} <br/>
<div class="text-grey"><span v-if="recipe.servingsText">{{ recipe.servingsText }}</span><span v-else>{{ $t('Servings') }}</span></div>
<number-scaler-dialog :number="servings" @confirm="(s: number) => {servings = s}" title="Servings">
</number-scaler-dialog>
</div>
</v-col>
</v-row>
</v-container>
</v-card>
<div class="cursor-pointer">
<i class="fas fa-sort-numeric-up fa-fw mr-1"></i> {{ servings }} <br/>
<div class="text-grey"><span v-if="recipe.servingsText">{{ recipe.servingsText }}</span><span v-else>{{ $t('Servings') }}</span></div>
<number-scaler-dialog :number="servings" @confirm="(s: number) => {servings = s}" title="Servings">
</number-scaler-dialog>
</div>
</v-col>
</v-row>
</v-container>
</v-card>
</template>
</template>
<!-- Desktop horizontal layout -->
<template class="d-none d-lg-block">
<v-row dense>
<v-col cols="8">
<recipe-image
:rounded="true"
max-height="40vh"
:recipe="recipe">
:recipe="recipe"
v-if="recipe.internal">
</recipe-image>
</v-col>
<v-col cols="4">
@@ -75,8 +75,7 @@
<v-card-text class="flex-grow-1">
<div class="d-flex">
<h1 class="flex-column flex-grow-1">{{ recipe.name }}</h1>
<recipe-context-menu :recipe="recipe" v-if="useUserPreferenceStore().isAuthenticated"
class="flex-column mb-auto mt-2 float-right"></recipe-context-menu>
<recipe-context-menu :recipe="recipe" v-if="useUserPreferenceStore().isAuthenticated" class="flex-column mb-auto mt-2 float-right"></recipe-context-menu>
</div>
<p>
{{ $t('created_by') }} {{ recipe.createdBy.displayName }} ({{ DateTime.fromJSDate(recipe.createdAt).toLocaleString(DateTime.DATE_SHORT) }})
@@ -85,8 +84,6 @@
<i>{{ recipe.description }}</i>
</p>
<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>
</v-card-text>
@@ -116,27 +113,20 @@
</v-row>
</template>
<template v-if="recipe.filePath">
<external-recipe-viewer class="mt-2" :recipe="recipe"></external-recipe-viewer>
<template v-if="!recipe.internal">
<external-recipe-viewer :recipe="recipe"></external-recipe-viewer>
</template>
<template v-else>
<v-card class="mt-1" v-if="recipe.steps.length > 1 && recipe.showIngredientOverview">
<steps-overview :steps="recipe.steps" :ingredient-factor="ingredientFactor"></steps-overview>
</v-card>
<v-card :title="$t('AI')" prepend-icon="$ai" @click="aiConvertRecipe()" :loading="fileApiLoading || loading" :disabled="fileApiLoading || loading"
v-if="!recipe.internal">
<v-card-text>
Convert the recipe using AI
</v-card-text>
<v-card class="mt-1" v-for="(step, index) in recipe.steps" :key="step.id">
<step-view v-model="recipe.steps[index]" :step-number="index+1" :ingredientFactor="ingredientFactor"></step-view>
</v-card>
</template>
<v-card class="mt-1" v-if="(recipe.steps.length > 1 || (recipe.steps.length == 1 && !recipe.steps[0].showIngredientsTable)) && recipe.showIngredientOverview">
<steps-overview :steps="recipe.steps" :ingredient-factor="ingredientFactor"></steps-overview>
</v-card>
<v-card class="mt-1" v-for="(step, index) in recipe.steps" :key="step.id">
<step-view v-model="recipe.steps[index]" :step-number="index+1" :ingredientFactor="ingredientFactor"></step-view>
</v-card>
<property-view v-model="recipe" :servings="servings"></property-view>
<property-view v-model="recipe" :servings="servings" v-if="recipe.internal"></property-view>
<v-card class="mt-2">
<v-card-text>
@@ -146,8 +136,7 @@
variant="outlined"
:title="$t('CreatedBy')"
:subtitle="recipe.createdBy.displayName"
prepend-icon="fa-solid fa-user"
:to="{name: 'SearchPage', query: {createdby: recipe.createdBy.id!}}">
prepend-icon="fa-solid fa-user">
</v-card>
</v-col>
<v-col cols="12" md="3">
@@ -155,8 +144,7 @@
variant="outlined"
: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()}}">
prepend-icon="$create">
</v-card>
</v-col>
<v-col cols="12" md="3">
@@ -164,8 +152,7 @@
variant="outlined"
: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()}}">
prepend-icon="$edit">
</v-card>
</v-col>
<v-col cols="12" md="3" v-if="recipe.sourceUrl">
@@ -189,7 +176,7 @@
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, watch} from 'vue'
import {ApiApi, Recipe} from "@/openapi"
import {Recipe} from "@/openapi"
import NumberScalerDialog from "@/components/inputs/NumberScalerDialog.vue"
import StepsOverview from "@/components/display/StepsOverview.vue";
import RecipeActivity from "@/components/display/RecipeActivity.vue";
@@ -197,33 +184,23 @@ import RecipeContextMenu from "@/components/inputs/RecipeContextMenu.vue";
import KeywordsComponent from "@/components/display/KeywordsBar.vue";
import RecipeImage from "@/components/display/RecipeImage.vue";
import ExternalRecipeViewer from "@/components/display/ExternalRecipeViewer.vue";
import {useWakeLock} from "@vueuse/core";
import {useMediaQuery, useWakeLock} from "@vueuse/core";
import StepView from "@/components/display/StepView.vue";
import {DateTime} from "luxon";
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";
const {request, release} = useWakeLock()
const {doAiImport, fileApiLoading} = useFileApi()
const loading = ref(false)
const recipe = defineModel<Recipe>({required: true})
const servings = ref(1)
const showFullRecipeName = ref(false)
/**
* factor for multiplying ingredient amounts based on recipe base servings and user selected servings
*/
const ingredientFactor = computed(() => {
return servings.value / ((recipe.value.servings != undefined) ? recipe.value.servings : 1)
})
/**
* change servings when recipe servings are changed
*/
watch(() => recipe.value.servings, () => {
if (recipe.value.servings) {
servings.value = recipe.value.servings
@@ -240,43 +217,6 @@ onBeforeUnmount(() => {
release()
})
/**
* converts the recipe into an internal recipe using AI
*/
function aiConvertRecipe() {
let api = new ApiApi()
doAiImport(null, '', recipe.value.id!).then(r => {
if (r.recipe) {
recipe.value.internal = true
recipe.value.steps = r.recipe.steps
recipe.value.keywords = r.recipe.keywords
recipe.value.servings = r.recipe.servings
recipe.value.servingsText = r.recipe.servingsText
recipe.value.workingTime = r.recipe.workingTime
recipe.value.waitingTime = r.recipe.waitingTime
servings.value = r.recipe.servings
loading.value = true
api.apiRecipeUpdate({id: recipe.value.id!, recipe: recipe.value}).then(r => {
recipe.value = r
}).catch(err => {
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
}).finally(() => {
loading.value = false
})
} else {
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, [r.error, r.msg])
}
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
})
}
</script>
<style scoped>

View File

@@ -1,5 +1,5 @@
<template>
<v-list-item class="swipe-container border-t-sm" :id="itemContainerId" @touchend="handleSwipe()"
<v-list-item class="swipe-container" :id="itemContainerId" @touchend="handleSwipe()"
v-if="isShoppingListFoodVisible(props.shoppingListFood, useUserPreferenceStore().deviceSettings)"
>
<!-- <div class="swipe-action" :class="{'bg-success': !isChecked , 'bg-warning': isChecked }">-->
@@ -15,8 +15,8 @@
<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>
{{ $n(a.amount) }}
<span v-if="a.unit">{{ a.unit.name }}</span>
</span>
</b>

View File

@@ -123,8 +123,6 @@
</template>
</v-list>
<!-- TODO remove once append to body for model select is working properly -->
<v-spacer style="margin-top: 120px;"></v-spacer>
</v-col>
</v-row>
@@ -182,8 +180,7 @@
<template #append>
<v-btn icon="$create" color="create" :disabled="manualAddRecipe == undefined">
<v-icon icon="$create"></v-icon>
<add-to-shopping-dialog :recipe="manualAddRecipe" v-if="manualAddRecipe != undefined"
@created="useShoppingStore().refreshFromAPI(); manualAddRecipe = undefined"></add-to-shopping-dialog>
<add-to-shopping-dialog :recipe="manualAddRecipe" v-if="manualAddRecipe != undefined" @created="useShoppingStore().refreshFromAPI(); manualAddRecipe = undefined"></add-to-shopping-dialog>
</v-btn>
</template>
</ModelSelect>

View File

@@ -23,7 +23,7 @@
<timer :seconds="step.time != undefined ? step.time*60 : 0" @stop="timerRunning = false" v-if="timerRunning"></timer>
<v-card-text v-if="step.ingredients.length > 0 || step.instruction != ''">
<v-row>
<v-col cols="12" md="6" v-if="step.ingredients.length > 0 && step.showIngredientsTable">
<v-col cols="12" md="6" v-if="step.ingredients.length > 0">
<ingredients-table v-model="step.ingredients" :ingredient-factor="ingredientFactor"></ingredients-table>
</v-col>
<v-col cols="12" md="6" class="markdown-body">
@@ -35,11 +35,11 @@
</v-card-text>
<template v-if="step.stepRecipe">
<v-card class="ma-2 border-md" prepend-icon="$recipes" :title="step.stepRecipeData.name">
<v-card-text class="mt-1" v-for="(subRecipeStep, subRecipeStepIndex) in step.stepRecipeData.steps" :key="subRecipeStep.id">
<v-card-text>
<v-card class="mt-1" v-for="(subRecipeStep, subRecipeStepIndex) in step.stepRecipeData.steps" :key="subRecipeStep.id">
<step-view v-model="step.stepRecipeData.steps[subRecipeStepIndex]" :step-number="subRecipeStepIndex+1" :ingredientFactor="ingredientFactor"></step-view>
</v-card-text>
</v-card>
</v-card>
</v-card-text>
</template>
<template v-if="step.file">
<v-img :src="step.file.preview" v-if="step.file.preview"></v-img>

View File

@@ -1,24 +1,13 @@
<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-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>
<template v-if="s.stepRecipe">
<v-card class="ma-2 border-md" prepend-icon="$recipes" :title="s.stepRecipeData.name"
:to="{name: 'RecipeViewPage', params: {id: s.stepRecipeData.id}}" target="_blank">
<v-row v-for="subRecipeStep in s.stepRecipeData.steps">
<v-col>
<ingredients-table v-model="subRecipeStep.ingredients" :ingredient-factor="props.ingredientFactor"></ingredients-table>
</v-col>
</v-row>
</v-card>
</template>
</v-col>
</v-row>
</v-container>
@@ -33,7 +22,6 @@
import {PropType} from 'vue'
import {Step} from "@/openapi";
import IngredientsTable from "@/components/display/IngredientsTable.vue";
import StepView from "@/components/display/StepView.vue";
const props = defineProps({
steps: {

View File

@@ -1,39 +0,0 @@
<template>
<v-select :label="$t('BaseUnit')" :hint="$t('BaseUnitHelp')" :items="BASE_UNITS" v-model="model"></v-select>
</template>
<script setup lang="ts">
import {useI18n} from "vue-i18n";
const {t} = useI18n()
const model = defineModel()
const BASE_UNITS = [
{value: "g", title: t("g")},
{value: "kg", title: t("kg")},
{value: "ounce", title: t("ounce")},
{value: "pound", title: t("pound")},
{value: "ml", title: t("ml")},
{value: "l", title: t("l")},
{value: "fluid_ounce", title: t("fluid_ounce")},
{value: "us_cup", title: t("us_cup")},
{value: "pint", title: t("pint")},
{value: "quart", title: t("quart")},
{value: "gallon", title: t("gallon")},
{value: "tbsp", title: t("tbsp")},
{value: "tsp", title: t("tsp")},
{value: "imperial_fluid_ounce", title: t("imperial_fluid_ounce")},
{value: "imperial_pint", title: t("imperial_pint")},
{value: "imperial_quart", title: t("imperial_quart")},
{value: "imperial_gallon", title: t("imperial_gallon")},
{value: "imperial_tbsp", title: t("imperial_tbsp")},
{value: "imperial_tsp", title: t("imperial_tsp")},
]
</script>
<style scoped>
</style>

View File

@@ -1,178 +0,0 @@
<template>
<v-row justify="space-between" dense>
<v-col cols="6">
<v-card :loading="loading" variant="outlined">
<v-card-text>
<v-treeview
v-model:activated="activeObjs"
return-object
activatable
rounded
indent-lines
hide-actions
density="compact"
open-all
item-title="name"
:items="objTree"
:disabled="loading">
<template v-slot:append="{ item, depth, isFirst, isLast }">
<v-icon icon="fa-solid fa-location-crosshairs" v-if="item.id == editingObj.id!"></v-icon>
</template>
</v-treeview>
</v-card-text>
</v-card>
</v-col>
<v-col cols="6">
<v-card v-if="activeObjs.length == 1" :title="activeObjs[0].name" :prepend-icon="genericModel.model.icon" variant="outlined">
<v-card-text>
<v-label>{{$t('AddChild')}}</v-label>
<model-select :model="genericModel.model.name" v-model="addChildObj" allow-create>
<template #append>
<v-btn color="save" icon="$save" :disabled="addChildObj == undefined" @click="moveObject(addChildObj,activeObjs[0].id!); addChildObj = undefined"></v-btn>
</template>
</model-select>
<v-label>{{$t('Parent')}}</v-label>
<model-select :model="genericModel.model.name" v-model="setParentObj" allow-create>
<template #append>
<v-btn color="save" icon="$save" :disabled="setParentObj == undefined" @click="moveObject(activeObjs[0], setParentObj.id!); setParentObj = undefined"></v-btn>
</template>
</model-select>
<v-btn @click="moveObject(activeObjs[0],0)" class="mt-2" color="warning" prepend-icon="fa-solid fa-link-slash" block>
{{$t('RemoveParent')}}
</v-btn>
<v-btn block prepend-icon="$edit" color="info" class="mt-4"
:to="{name: 'ModelEditPage' , params: {model: genericModel.model.name, id: activeObjs[0].id }}"
v-if="activeObjs[0].id != editingObj.id!">
{{ $t('Edit') }}
</v-btn>
<v-btn block prepend-icon="$search" color="success" class="mt-4 mb-10"
:to="searchLink" target="_blank"
v-if="searchLink">
{{ $t('Recipes') }}
</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script setup lang="ts">
import {computed, onMounted, PropType, ref} from "vue";
import ModelSelect from "@/components/inputs/ModelSelect.vue";
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
import {EditorSupportedModels, EditorSupportedTypes, getGenericModelFromString} from "@/types/Models.ts";
import {useI18n} from "vue-i18n";
type Tree<T> = T & { children: Tree<T>[] };
const props = defineProps({
model: {type: String as PropType<EditorSupportedModels>, required: true},
})
const editingObj = defineModel<EditorSupportedTypes>({required: true})
const t = useI18n()
/**
* compute tree structure based on object list
*/
const objTree = computed(() => {
return buildTreeDFS(objList.value)
})
/**
* link to search for recipes using the selected object
*/
const searchLink = computed(() => {
if (activeObjs.value.length == 1) {
if (props.model == 'Keyword') {
return {name: 'SearchPage', query: {keywords: activeObjs.value[0].id!}}
} else if (props.model == 'Food') {
return {name: 'SearchPage', query: {keywords: activeObjs.value[0].id!}}
}
}
return undefined
})
const loading = ref(false)
const objList = ref([] as EditorSupportedTypes[])
const activeObjs = ref([] as EditorSupportedTypes[])
const addChildObj = ref<undefined | EditorSupportedTypes>(undefined)
const setParentObj = ref<undefined | EditorSupportedTypes>(undefined)
const genericModel = ref(getGenericModelFromString(props.model, t))
onMounted(() => {
recLoadObjectTree(editingObj.value.id!, 1)
})
/**
* recursively load all objects belonging to the selected objects tree
* @param objId base object id to look for tree
* @param page page to load
*/
function recLoadObjectTree(objId: number, page: number) {
loading.value = true
genericModel.value.list({rootTree: objId, pageSize: 100}).then(response => {
objList.value = objList.value.concat(response.results)
if (response.next) {
recLoadObjectTree(objId, page + 1)
} else {
if (activeObjs.value.length == 0) {
activeObjs.value = [objTree.value.find(x => x.id! == editingObj.value.id!)]
}
loading.value = false
}
})
}
/**
* move the given obj to the desired parent and update in local data cache
* @param obj object to change parent for
* @param parentId parent id to change the object to or 0 to remove parent
*/
function moveObject(obj: EditorSupportedTypes, parentId: number) {
loading.value = true
genericModel.value.move(obj, parentId).then((r: any) => {
objList.value = []
recLoadObjectTree(editingObj.value.id!, 1)
}).catch((err: any) => {
loading.value = false
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
})
}
/**
* Recursively build a tree datastructures from a DFS ordered list of objects
*/
function buildTreeDFS<T extends { id: number; parent: number | null }>(items: T[]): Tree<T>[] {
let index = 0;
function buildSubtree(parentId: number | null): Tree<T>[] {
const children: Tree<T>[] = [];
while (index < items.length && items[index].parent === parentId) {
const item = items[index++];
const node: Tree<T> = {
...item,
children: buildSubtree(item.id) // recurse immediately, consumes children in DFS order
};
children.push(node);
}
return children;
}
return buildSubtree(null); // start from roots (parent === null)
}
</script>
<style scoped>
</style>

View File

@@ -23,6 +23,7 @@
valueProp="id"
label="name"
:delay="300"
append-to-body
:searchable="true"
:strict="false"
:classes="{
@@ -67,7 +68,9 @@ function addIngredient(amount: number, unit: Unit|null, food: Food|null) {
food: food,
} as ShoppingListEntry
console.log('adding SLR ? ', props.mealPlan)
if (props.mealPlan) {
console.log('yes')
sle.mealplanId = props.mealPlan.id
}
@@ -131,6 +134,7 @@ function search(query: string) {
</script>
<style src="@vueform/multiselect/themes/default.css"></style>
<!-- style can't be scoped (for whatever reason) -->
<style>

View File

@@ -47,8 +47,7 @@
<v-number-input :label="$t('Time')" v-model="step.time" :min="0" :step="5" control-variant="split"></v-number-input>
</v-col>
<v-col cols="12" md="6" v-if="showRecipe || step.stepRecipe != null">
<model-select model="Recipe" v-model="step.stepRecipeData"
@update:modelValue="step.stepRecipe = (step.stepRecipeData != null) ? step.stepRecipeData.id! : null"></model-select>
<model-select model="Recipe" v-model="step.stepRecipeData" @update:modelValue="step.stepRecipe = (step.stepRecipeData != null) ? step.stepRecipeData.id! : null"></model-select>
</v-col>
<v-col cols="12" md="6" v-if="showFile || step.file != null">
<model-select model="UserFile" v-model="step.file"></model-select>
@@ -61,10 +60,6 @@
<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-icon icon="$import" size="x-small"></v-icon>
{{ ingredient.originalText }}
</v-col>
<v-col cols="2" v-if="!ingredient.isHeader">
<v-input hide-details>
<template #prepend>
@@ -104,22 +99,26 @@
<v-list-item link>
<v-switch v-model="step.ingredients[index].noAmount" :label="$t('Disable_Amount')" hide-details></v-switch>
</v-list-item>
<v-list-item @click="editingIngredientIndex = index; dialogIngredientSorter = true" prepend-icon="fa-solid fa-sort">
{{ $t('Move') }}
<v-list-item @click="editingIngredientIndex = index; dialogIngredientSorter = true" prepend-icon="fa-solid fa-sort">{{
$t('Move')
}}
</v-list-item>
<v-list-item v-if="ingredient.originalText" prepend-icon="$import">
<v-list-item-title>{{ $t('Original_Text') }}</v-list-item-title>
<v-list-item-subtitle>{{ ingredient.originalText }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
</v-col>
</v-col>
</v-row>
</vue-draggable>
</div>
<v-list v-if="mobile">
<vue-draggable v-model="step.ingredients" handle=".drag-handle" :on-sort="sortIngredients" group="ingredients" empty-insert-threshold="25">
<v-list-item v-for="(ingredient, index) in step.ingredients" :key="ingredient.id" border
@click="editingIngredientIndex = index; dialogIngredientEditor = true">
<v-list-item v-for="(ingredient, index) in step.ingredients" :key="ingredient.id" border @click="editingIngredientIndex = index; dialogIngredientEditor = true">
<ingredient-string :ingredient="ingredient"></ingredient-string>
<template #append>
<v-icon icon="$dragHandle" class="drag-handle"></v-icon>

View File

@@ -15,7 +15,6 @@
<v-tab value="food">{{ $t('Food') }}</v-tab>
<v-tab value="properties">{{ $t('Properties') }}</v-tab>
<v-tab value="conversions">{{ $t('Conversion') }}</v-tab>
<v-tab value="hierarchy">{{ $t('Hierarchy') }}</v-tab>
<v-tab value="misc">{{ $t('Miscellaneous') }}</v-tab>
</v-tabs>
</v-card-text>
@@ -52,9 +51,6 @@
<model-select :label="$t('Properties_Food_Unit')" v-model="editingObj.propertiesFoodUnit" model="Unit"></model-select>
<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-form>
</v-tabs-window-item>
@@ -84,7 +80,7 @@
</v-col>
<v-col md="6">
<!-- TODO fix card overflow invisible, overflow-visible class is not working -->
<model-select v-model="uc.baseUnit" model="Unit" hide-details></model-select>
<model-select v-model="uc.baseUnit" model="Unit" hide-details></model-select>
</v-col>
</v-row>
<v-row dense>
@@ -98,27 +94,13 @@
</v-col>
<v-col md="6">
<!-- TODO fix card overflow invisible, overflow-visible class is not working -->
<model-select v-model="uc.convertedUnit" model="Unit"></model-select>
<model-select v-model="uc.convertedUnit" model="Unit"></model-select>
</v-col>
</v-row>
</v-card-text>
</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-tabs-window-item>
<v-tabs-window-item value="hierarchy">
<hierarchy-editor v-model="editingObj" :model="modelClass.model.name"></hierarchy-editor>
<v-checkbox :label="$t('substitute_siblings')" :hint="$t('substitute_siblings_help')" v-model="editingObj.substituteSiblings" persistent-hint></v-checkbox>
<v-checkbox :label="$t('substitute_children')" :hint="$t('substitute_children_help')" v-model="editingObj.substituteChildren" persistent-hint></v-checkbox>
<ModelSelect model="FoodInheritField" v-model="editingObj.inheritFields" :label="$t('InheritFields')" :hint="$t('InheritFields_help')"
mode="tags"></ModelSelect>
<ModelSelect model="FoodInheritField" v-model="editingObj.childInheritFields" :label="$t('ChildInheritFields')" :hint="$t('ChildInheritFields_help')"
mode="tags"></ModelSelect>
</v-tabs-window-item>
<v-tabs-window-item value="misc">
@@ -130,6 +112,14 @@
<v-divider class="mt-2 mb-2"></v-divider>
<ModelSelect model="Food" v-model="editingObj.substitute" :label="$t('Substitutes')" :hint="$t('substitute_help')" mode="tags"></ModelSelect>
<v-checkbox :label="$t('substitute_siblings')" :hint="$t('substitute_siblings_help')" v-model="editingObj.substituteSiblings" persistent-hint></v-checkbox>
<v-checkbox :label="$t('substitute_children')" :hint="$t('substitute_children_help')" v-model="editingObj.substituteChildren" persistent-hint></v-checkbox>
<ModelSelect model="FoodInheritField" v-model="editingObj.inheritFields" :label="$t('InheritFields')" :hint="$t('InheritFields_help')"
mode="tags"></ModelSelect>
<ModelSelect model="FoodInheritField" v-model="editingObj.childInheritFields" :label="$t('ChildInheritFields')" :hint="$t('ChildInheritFields_help')"
mode="tags"></ModelSelect>
<!-- TODO re-add reset inheritance button/api call /function (previously annotated field on food -->
<v-text-field :label="$t('Open_Data_Slug')" :hint="$t('open_data_help_text')" persistent-hint v-model="editingObj.openDataSlug" disabled></v-text-field>
</v-form>
@@ -159,7 +149,6 @@ import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import FdcSearchDialog from "@/components/dialogs/FdcSearchDialog.vue";
import {openFdcPage} from "@/utils/fdc.ts";
import {DateTime} from "luxon";
import HierarchyEditor from "@/components/inputs/HierarchyEditor.vue";
const props = defineProps({
@@ -218,7 +207,7 @@ onMounted(() => {
/**
* component specific state setup logic
*/
function initializeEditor() {
function initializeEditor(){
setupState(props.item, props.itemId, {
newItemFunction: () => {
editingObj.value.propertiesFoodAmount = 100

View File

@@ -16,11 +16,6 @@
<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)">
<template #append-inner>
<btn-copy variant="plain" color="undefined" :copy-value="inviteLinkUrl(editingObj)"></btn-copy>
</template>
</v-text-field>
</v-form>
</v-card-text>
</model-editor-base>
@@ -36,7 +31,6 @@ import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
import {DateTime} from "luxon";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
import BtnCopy from "@/components/buttons/BtnCopy.vue";
const props = defineProps({
@@ -85,16 +79,6 @@ function initializeEditor(){
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
})
}
/**
* 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>

View File

@@ -9,29 +9,13 @@
: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="keyword">{{ $t('Keyword') }}</v-tab>
<v-tab value="hierarchy">{{ $t('Hierarchy') }}</v-tab>
</v-tabs>
</v-card-text>
<v-card-text>
<v-tabs-window v-model="tab">
<v-tabs-window-item value="keyword">
<v-form :disabled="loading">
<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('Name')" v-model="editingObj.name"></v-text-field>
<v-textarea :label="$t('Description')" v-model="editingObj.description"></v-textarea>
</v-form>
</v-tabs-window-item>
<v-tabs-window-item value="hierarchy">
<hierarchy-editor v-model="editingObj" :model="modelClass.model.name"></hierarchy-editor>
</v-tabs-window-item>
</v-tabs-window>
</v-form>
</v-card-text>
</model-editor-base>
@@ -39,11 +23,10 @@
<script setup lang="ts">
import {onMounted, PropType, ref, watch} from "vue";
import {onMounted, PropType, watch} from "vue";
import {Keyword} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
import HierarchyEditor from "@/components/inputs/HierarchyEditor.vue";
const props = defineProps({
item: {type: {} as PropType<Keyword>, required: false, default: null},
@@ -55,8 +38,6 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<Keyword>('Keyword', emit)
const tab = ref("keyword")
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
@@ -74,7 +55,7 @@ onMounted(() => {
/**
* component specific state setup logic
*/
function initializeEditor() {
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}

View File

@@ -28,7 +28,7 @@
@update:modelValue="editingObj.servings = editingObj.recipe ? editingObj.recipe.servings : 1"></ModelSelect>
<!-- <v-number-input label="Days" control-variant="split" :min="1"></v-number-input>-->
<!--TODO create days input with +/- synced to date -->
<recipe-card :recipe="editingObj.recipe" v-if="editingObj && editingObj.recipe" link-target="_blank"></recipe-card>
<recipe-card :recipe="editingObj.recipe" v-if="editingObj && editingObj.recipe"></recipe-card>
<v-btn prepend-icon="$shopping" color="create" class="mt-1" v-if="!editingObj.shopping && editingObj.recipe && isUpdate()">
{{$t('Add')}}
<add-to-shopping-dialog :recipe="editingObj.recipe" :meal-plan="editingObj" @created="loadShoppingListEntries(); editingObj.shopping = true;"></add-to-shopping-dialog>
@@ -47,7 +47,6 @@
multiple="range"
prepend-icon=""
prepend-inner-icon="$calendar"
hide-details
></v-date-input>
<v-input>
@@ -78,7 +77,7 @@
<v-tabs-window-item value="shopping">
<closable-help-alert class="mb-2" :text="$t('MealPlanShoppingHelp')"></closable-help-alert>
<v-row v-if="isUpdate()" dense style="max-height: 75vh; min-height: 30vh" class="overflow-y-scroll">
<v-row v-if="isUpdate()" dense style="max-height: 75vh" class="overflow-y-scroll">
<v-col>
<shopping-list-entry-input :loading="useShoppingStore().currentlyUpdating" :meal-plan="editingObj"></shopping-list-entry-input>
<v-list v-if="editingObj.id">

View File

@@ -24,7 +24,7 @@
<v-form :disabled="loading || fileApiLoading">
<v-text-field :label="$t('Name')" v-model="editingObj.name"></v-text-field>
<v-textarea :label="$t('Description')" v-model="editingObj.description" clearable counter="512" maxlength="512" rows="2" auto-grow></v-textarea>
<v-textarea :label="$t('Description')" v-model="editingObj.description" clearable counter="512" rows="2" auto-grow></v-textarea>
<v-row>
<v-col cols="12" md="6">
@@ -50,13 +50,13 @@
<model-select mode="tags" v-model="editingObj.keywords" model="Keyword" allow-create></model-select>
<v-row dense>
<v-col cols="12" md="6">
<v-number-input :label="$t('WaitingTime')" v-model="editingObj.waitingTime" :step="5"></v-number-input>
<v-text-field :label="$t('WaitingTime')" v-model="editingObj.waitingTime"></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-number-input :label="$t('WorkingTime')" v-model="editingObj.workingTime" :step="5"></v-number-input>
<v-text-field :label="$t('WorkingTime')" v-model="editingObj.workingTime"></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-number-input :label="$t('Servings')" v-model="editingObj.servings"></v-number-input>
<v-text-field :label="$t('Servings')" v-model="editingObj.servings"></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field :label="$t('ServingsText')" v-model="editingObj.servingsText"></v-text-field>
@@ -100,8 +100,8 @@
v-model="editingObj.showIngredientOverview"></v-checkbox>
<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"
<v-checkbox :label="$t('Private_Recipe')" persistent-hint v-model="editingObj._private"></v-checkbox>
<model-select mode="tags" model="User" :label="$t('Private_Recipe')" :hint="$t('Private_Recipe_Help')" persistent-hint v-model="editingObj.shared"
append-to-body v-if="editingObj._private"></model-select>
</v-form>

View File

@@ -39,11 +39,6 @@
<model-select :label="$t('Unit')" v-model="editingObj.convertedUnit" model="Unit"></model-select>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field :label="$t('Open_Data_Slug')" :hint="$t('open_data_help_text')" persistent-hint v-model="editingObj.openDataSlug" disabled></v-text-field>
</v-col>
</v-row>
</v-form>
</v-card-text>
</model-editor-base>

View File

@@ -14,7 +14,7 @@
<v-text-field :label="$t('Name')" v-model="editingObj.name"></v-text-field>
<v-text-field :label="$t('Plural')" v-model="editingObj.pluralName"></v-text-field>
<v-textarea :label="$t('Description')" v-model="editingObj.description"></v-textarea>
<base-unit-select v-model="editingObj.baseUnit"></base-unit-select>
<v-select :label="$t('BaseUnit')" :hint="$t('BaseUnitHelp')" :items="BASE_UNITS" v-model="editingObj.baseUnit"></v-select>
<v-text-field :label="$t('Open_Data_Slug')" :hint="$t('open_data_help_text')" persistent-hint v-model="editingObj.openDataSlug" disabled></v-text-field>
</v-form>
</v-card-text>
@@ -29,7 +29,6 @@ import {Unit} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
import {useI18n} from "vue-i18n";
import BaseUnitSelect from "@/components/inputs/BaseUnitSelect.vue";
const {t} = useI18n()
@@ -52,7 +51,27 @@ watch([() => props.item, () => props.itemId], () => {
// object specific data (for selects/display)
const BASE_UNITS = [
{value: "g", title: t("g")},
{value: "kg", title: t("kg")},
{value: "ounce", title: t("ounce")},
{value: "pound", title: t("pound")},
{value: "ml", title: t("ml")},
{value: "l", title: t("l")},
{value: "fluid_ounce", title: t("fluid_ounce")},
{value: "us_cup", title: t("us_cup")},
{value: "pint", title: t("pint")},
{value: "quart", title: t("quart")},
{value: "gallon", title: t("gallon")},
{value: "tbsp", title: t("tbsp")},
{value: "tsp", title: t("tsp")},
{value: "imperial_fluid_ounce", title: t("imperial_fluid_ounce")},
{value: "imperial_pint", title: t("imperial_pint")},
{value: "imperial_quart", title: t("imperial_quart")},
{value: "imperial_gallon", title: t("imperial_gallon")},
{value: "imperial_tbsp", title: t("imperial_tbsp")},
{value: "imperial_tsp", title: t("imperial_tsp")},
]
onMounted(() => {
initializeEditor()

View File

@@ -4,9 +4,9 @@
<v-divider class="mb-3"></v-divider>
<v-row>
<database-link-col prepend-icon="fa-solid fa-terminal" :href="useDjangoUrls().getDjangoUrl('api')" :lg="6" :title="$t('API_Browser')"></database-link-col>
<database-link-col prepend-icon="fa-solid fa-terminal" :href="useDjangoUrls().getDjangoUrl('api')" :lg="6" :title="$t('API Browser')"></database-link-col>
<database-link-col prepend-icon="fa-solid fa-laptop-code" :href="useDjangoUrls().getDjangoUrl('/docs/api/')" :lg="6"
:title="$t('API_Documentation')"></database-link-col>
:title="$t('API Dokumentation')"></database-link-col>
</v-row>
<v-row>

View File

@@ -27,7 +27,7 @@
<v-divider class="mb-3"></v-divider>
<v-text-field v-model="useUserPreferenceStore().userSettings.defaultUnit" :label="$t('Default_Unit')"></v-text-field>
<v-number-input v-model="useUserPreferenceStore().userSettings.ingredientDecimals" :label="$t('Decimals')"></v-number-input>
<!-- <v-text-field v-model="useUserPreferenceStore().userSettings.ingredientDecimals" :label="$t('Decimals')"></v-text-field>-->
<!-- <v-select-->
<!-- :label="$t('DefaultPage')"-->

View File

@@ -27,11 +27,6 @@
<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">
@@ -70,7 +65,6 @@ 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'},
]
@@ -83,7 +77,7 @@ onMounted(() => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
})
api.apiInviteLinkList({unused: true}).then(r => {
api.apiInviteLinkList().then(r => {
spaceInviteLinks.value = r.results
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)

View File

@@ -84,9 +84,8 @@ export function useFileApi() {
* uploads the given file to the image recognition endpoint
* @param file file object to upload
* @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(file: File | null, text: string = '') {
let formData = new FormData()
if (file != null) {
@@ -95,8 +94,6 @@ export function useFileApi() {
formData.append('file', '')
}
formData.append('text', text)
formData.append('recipe_id', recipeId)
fileApiLoading.value = true
return fetch(getDjangoUrl(`api/ai-import/`), {
method: 'POST',

View File

@@ -5,7 +5,6 @@ import {useI18n} from "vue-i18n";
import {ResponseError} from "@/openapi";
import {getNestedProperty} from "@/utils/utils";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import {useTitle} from "@vueuse/core";
// TODO type emit parameter (https://mokkapps.de/vue-tips/emit-event-from-composable)
// TODO alternatively there seems to be a getContext method to get the calling context (good practice?)
@@ -19,7 +18,6 @@ export function useModelEditorFunctions<T>(modelName: EditorSupportedModels, emi
const editingObjChanged = ref(false)
const {t} = useI18n()
const title = useTitle()
/**
* watch editing object to detect changes
@@ -110,14 +108,12 @@ export function useModelEditorFunctions<T>(modelName: EditorSupportedModels, emi
newItemFunction()
loading.value = false
title.value = editingObjName()
return Promise.resolve(editingObj.value)
} else if (item !== null) {
// item is given so return that
editingObj.value = item
existingItemFunction()
loading.value = false
title.value = editingObjName()
return Promise.resolve(editingObj.value)
} else if (itemId !== undefined && itemId != '') {
// itemId is given => fetch from server and return item
@@ -130,7 +126,6 @@ export function useModelEditorFunctions<T>(modelName: EditorSupportedModels, emi
return modelClass.value.retrieve(itemId).then((r: T) => {
editingObj.value = r
existingItemFunction()
title.value = editingObjName()
return editingObj.value
}).catch((err: any) => {
if (err instanceof ResponseError && err.response.status == 404) {

View File

@@ -2,8 +2,6 @@ import {useI18n} from "vue-i18n";
import {VDivider, VListItem} from "vuetify/components";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
import {useDjangoUrls} from "@/composables/useDjangoUrls.ts";
import {TANDOOR_PLUGINS} from "@/types/Plugins.ts";
import {plugin} from "@/plugins/open_data_plugin/plugin.ts";
/**
* manages configuration and loading of navigation entries for tandoor main app and plugins
@@ -11,50 +9,27 @@ import {plugin} from "@/plugins/open_data_plugin/plugin.ts";
export function useNavigation() {
const {t} = useI18n()
function getNavigationDrawer() {
let navigation = [
{component: VListItem, prependIcon: '$recipes', title: 'Home', to: {name: 'StartPage', params: {}}},
{component: VListItem, prependIcon: '$search', title: t('Search'), to: {name: 'SearchPage', params: {}}},
{component: VListItem, prependIcon: '$mealplan', title: t('Meal_Plan'), to: {name: 'MealPlanPage', params: {}}},
{component: VListItem, prependIcon: '$shopping', title: t('Shopping_list'), to: {name: 'ShoppingListPage', params: {}}},
{component: VListItem, prependIcon: 'fas fa-globe', title: t('Import'), to: {name: 'RecipeImportPage', params: {}}},
{component: VListItem, prependIcon: '$books', title: t('Books'), to: {name: 'BooksPage', params: {}}},
{component: VListItem, prependIcon: 'fa-solid fa-folder-tree', title: t('Database'), to: {name: 'DatabasePage', params: {}}},
]
let NAVIGATION_DRAWER = [
{component: VListItem, prependIcon: '$recipes', title: 'Home', to: {name: 'StartPage', params: {}}},
{component: VListItem, prependIcon: '$search', title: t('Search'), to: {name: 'SearchPage', params: {}}},
{component: VListItem, prependIcon: '$mealplan', title: t('Meal_Plan'), to: {name: 'MealPlanPage', params: {}}},
{component: VListItem, prependIcon: '$shopping', title: t('Shopping_list'), to: {name: 'ShoppingListPage', params: {}}},
{component: VListItem, prependIcon: 'fas fa-globe', title: t('Import'), to: {name: 'RecipeImportPage', params: {}}},
{component: VListItem, prependIcon: '$books', title: t('Books'), to: {name: 'BooksPage', params: {}}},
{component: VListItem, prependIcon: 'fa-solid fa-folder-tree', title: t('DatabasePage'), to: {name: 'SearchPage', params: {}}},
]
TANDOOR_PLUGINS.forEach(plugin => {
plugin.navigationDrawer.forEach(navEntry => {
let navEntryCopy = Object.assign({}, navEntry)
if ('title' in navEntryCopy) {
navEntryCopy.title = t(navEntryCopy.title)
}
navigation.push(navEntryCopy)
})
})
let BOTTOM_NAVIGATION = [
{component: VListItem, prependIcon: 'fa-solid fa-sliders', title: t('Settings'), to: {name: 'SettingsPage', params: {}}},
{component: VListItem, prependIcon: 'fas fa-globe', title: t('Import'), to: {name: 'RecipeImportPage', params: {}}},
{component: VListItem, prependIcon: 'fa-solid fa-folder-tree', title: t('Database'), to: {name: 'DatabasePage', params: {}}},
{component: VListItem, prependIcon: '$books', title: t('Books'), to: {name: 'BooksPage', params: {}}},
]
return navigation
}
function getBottomNavigation() {
let navigation = [
{component: VListItem, prependIcon: 'fa-solid fa-sliders', title: t('Settings'), to: {name: 'SettingsPage', params: {}}},
{component: VListItem, prependIcon: 'fas fa-globe', title: t('Import'), to: {name: 'RecipeImportPage', params: {}}},
{component: VListItem, prependIcon: 'fa-solid fa-folder-tree', title: t('Database'), to: {name: 'DatabasePage', params: {}}},
{component: VListItem, prependIcon: '$books', title: t('Books'), to: {name: 'BooksPage', params: {}}},
]
TANDOOR_PLUGINS.forEach(plugin => {
plugin.bottomNavigation.forEach(navEntry => {
let navEntryCopy = Object.assign({}, navEntry)
if ('title' in navEntryCopy) {
navEntryCopy.title = t(navEntryCopy.title)
}
navigation.push(navEntryCopy)
})
})
return navigation
}
let USER_NAVIGATION = [
{component: VListItem, prependIcon: 'fa-solid fa-sliders', title: t('Settings'), to: {name: 'SettingsPage', params: {}}},
{component: VListItem, prependIcon: 'fa-solid fa-question', title: t('Settings'), to: {name: 'HelpPage', params: {}}},
]
function getUserNavigation() {
let navigation = []
@@ -81,20 +56,26 @@ export function useNavigation() {
navigation.push({component: VDivider})
}
TANDOOR_PLUGINS.forEach(plugin => {
plugin.userNavigation.forEach(navEntry => {
let navEntryCopy = Object.assign({}, navEntry)
if ('title' in navEntryCopy) {
navEntryCopy.title = t(navEntryCopy.title)
}
navigation.push(navEntryCopy)
})
})
navigation.push({component: VListItem, prependIcon: 'fa-solid fa-arrow-right-from-bracket', title: t('Logout'), href: useDjangoUrls().getDjangoUrl('accounts/logout')})
return navigation
}
return {getNavigationDrawer, getBottomNavigation, getUserNavigation}
}
return {NAVIGATION_DRAWER, BOTTOM_NAVIGATION, USER_NAVIGATION, getUserNavigation}
}
//
// <v-list-item :href="getDjangoUrl('admin')" target="_blank" v-if="useUserPreferenceStore().userSettings.user.isSuperuser">
// <template #prepend>
// <v-icon icon="fa-solid fa-shield"></v-icon>
// </template>
// {{ $t('Admin') }}
// </v-list-item>
// <v-list-item :href="getDjangoUrl('accounts/logout')" link>
// <template #prepend>
// <v-icon icon="fa-solid fa-arrow-right-from-bracket"></v-icon>
// </template>
// {{ $t('Logout') }}
// </v-list-item>

View File

@@ -7,7 +7,6 @@ import type {
import {createI18n} from "vue-i18n";
import en from "../../vue3/src/locales/en.json";
import {TANDOOR_PLUGINS} from "@/types/Plugins.ts";
/**
* lazy loading of translation, resources:
@@ -32,17 +31,10 @@ export function setupI18n() {
locale: 'en',
fallbackLocale: 'en',
messages: {
en,
en
},
}) as I18n
// async load plugin default locales
TANDOOR_PLUGINS.forEach(plugin => {
plugin.defaultLocale.then(pluginMessages => {
i18n.global.mergeLocaleMessage('en', pluginMessages)
})
})
// async load user locale into existing i18n instance
loadLocaleMessages(i18n, locale).then()
@@ -56,10 +48,9 @@ export function setupI18n() {
*/
export async function loadLocaleMessages(i18n: I18n, locale: Locale) {
// load locale messages
let messages = en
if (locale != 'en') {
messages = await import(`./locales/${locale}.json`).then((r: any) => r.default || r)
}
const messages = await import(`./locales/${locale}.json`).then(
(r: any) => r.default || r
)
// remove empty strings
Object.entries(messages).forEach(([key, value]) => {
@@ -71,25 +62,6 @@ export async function loadLocaleMessages(i18n: I18n, locale: Locale) {
// set messages for locale
i18n.global.setLocaleMessage(locale, messages)
// async load and merge messages from plugins
TANDOOR_PLUGINS.forEach(plugin => {
let pluginLocales = getSupportedLocales(plugin.localeFiles)
if (pluginLocales.includes(locale)) {
import(`@/plugins/${plugin.basePath}/locales/${locale}.json`).then((r: any) => {
let pluginMessages = r.default || r
// remove empty strings
Object.entries(pluginMessages).forEach(([key, value]) => {
if (value === '') {
delete pluginMessages[key]
}
})
i18n.global.mergeLocaleMessage(locale, pluginMessages)
})
}
})
// switch to given locale
setLocale(i18n, locale)
}
@@ -97,11 +69,10 @@ export async function loadLocaleMessages(i18n: I18n, locale: Locale) {
/**
* loop trough translation files to determine for which locales a translation is available
* @return string[] of supported locales
* @param localeFiles module import of locale files to loop trough
*/
function getSupportedLocales(localeFiles = import.meta.glob('@/locales/*.json')) {
function getSupportedLocales() {
let supportedLocales: string[] = []
let localeFiles = import.meta.glob('@/locales/*.json');
for (const path in localeFiles) {
supportedLocales.push(path.split('/').slice(-1)[0].split('.')[0]);
}

View File

@@ -1,8 +1,5 @@
{
"API_Browser": "",
"API_Documentation": "",
"Add": "",
"AddChild": "",
"AddFoodToShopping": "",
"AddToShopping": "",
"Add_Servings_to_Shopping": "",
@@ -20,13 +17,8 @@
"Auto_Planner": "",
"Automate": "",
"Automation": "",
"BatchDeleteConfirm": "",
"BatchDeleteHelp": "",
"BatchEdit": "",
"BatchEditUpdatingItemsCount": "",
"Bookmarklet": "",
"Books": "",
"CREATE_ERROR": "",
"Calories": "",
"Cancel": "",
"Cannot_Add_Notes_To_Shopping": "",
@@ -60,19 +52,16 @@
"Create_New_Unit": "",
"Current_Period": "",
"Custom Filter": "",
"DELETE_ERROR": "",
"Date": "",
"DelayFor": "",
"DelayUntil": "",
"Delete": "",
"DeleteShoppingConfirm": "",
"DeleteSomething": "",
"Delete_Food": "",
"Delete_Keyword": "",
"Description": "",
"Disable_Amount": "",
"Documentation": "",
"DontChange": "",
"Download": "",
"Drag_Here_To_Delete": "",
"Edit": "",
@@ -89,9 +78,7 @@
"Export_Supported": "",
"Export_To_ICal": "",
"External": "",
"ExternalRecipe": "",
"External_Recipe_Image": "",
"FETCH_ERROR": "",
"Failure": "",
"Fats": "",
"File": "",
@@ -112,7 +99,6 @@
"Hide_Keywords": "",
"Hide_Recipes": "",
"Hide_as_header": "",
"Hierarchy": "",
"Icon": "",
"IgnoreAccents": "",
"IgnoreAccentsHelp": "",
@@ -159,10 +145,8 @@
"Meal_Type_Required": "",
"Meal_Types": "",
"Merge": "",
"MergeAutomateHelp": "",
"Merge_Keyword": "",
"Message": "",
"MissingProperties": "",
"Month": "",
"Move": "",
"MoveCategory": "",
@@ -184,9 +168,7 @@
"New_Unit": "",
"Next_Day": "",
"Next_Period": "",
"No": "",
"NoCategory": "",
"NoUnit": "",
"No_ID": "",
"No_Results": "",
"NotInShopping": "",
@@ -218,8 +200,6 @@
"Previous_Day": "",
"Previous_Period": "",
"Print": "",
"Private": "",
"Private_Recipe_Help": "",
"Protected": "",
"Proteins": "",
"Quick actions": "",
@@ -234,9 +214,7 @@
"Recipes": "",
"Recipes_In_Import": "",
"Recipes_per_page": "",
"RemoveAllType": "",
"RemoveFoodFromShopping": "",
"RemoveParent": "",
"Remove_nutrition_recipe": "",
"Reset": "",
"Reset_Search": "",
@@ -267,7 +245,6 @@
"Single": "",
"Size": "",
"Sort_by_new": "",
"Space": "",
"Starting_Day": "",
"StartsWith": "",
"StartsWithHelp": "",
@@ -291,7 +268,6 @@
"TrigramThreshold": "",
"TrigramThresholdHelp": "",
"Type": "",
"UPDATE_ERROR": "",
"Undefined": "",
"Unit": "",
"Unit_Alias": "",
@@ -307,7 +283,6 @@
"Valid Until": "",
"View": "",
"View_Recipes": "",
"Visibility": "",
"Waiting": "",
"Warning": "",
"Warning_Delete_Supermarket_Category": "",
@@ -315,7 +290,6 @@
"Week": "",
"Week_Numbers": "",
"Year": "",
"Yes": "",
"add_keyword": "",
"additional_options": "",
"advanced": "",

View File

@@ -1,8 +1,5 @@
{
"API_Browser": "",
"API_Documentation": "",
"Add": "Добави",
"AddChild": "",
"AddFoodToShopping": "Добавете {food} към списъка си за пазаруване",
"AddToShopping": "Добавяне към списъка за пазаруване",
"Add_Servings_to_Shopping": "Добавете {servings} порции към Пазаруване",
@@ -20,13 +17,8 @@
"Auto_Planner": "Автоматичен плановик",
"Automate": "Автоматизация",
"Automation": "Автоматизация",
"BatchDeleteConfirm": "",
"BatchDeleteHelp": "",
"BatchEdit": "",
"BatchEditUpdatingItemsCount": "",
"Bookmarklet": "Книжен пазар",
"Books": "Книги",
"CREATE_ERROR": "",
"Calories": "Калории",
"Cancel": "Откажи",
"Cannot_Add_Notes_To_Shopping": "Бележки не могат да се добавят към списъка за пазаруване",
@@ -57,19 +49,16 @@
"Create_New_Unit": "Добавяне на нова единица",
"Current_Period": "Текущ период",
"Custom Filter": "Персонализиран филтър",
"DELETE_ERROR": "",
"Date": "Дата",
"DelayFor": "Закъснение за {hours} часа",
"DelayUntil": "Забавяне до",
"Delete": "Изтрий",
"DeleteShoppingConfirm": "Сигурни ли сте, че искате да премахнете цялата {food} от списъка за пазаруване?",
"DeleteSomething": "",
"Delete_Food": "Изтриване на храна",
"Delete_Keyword": "Изтриване на ключова дума",
"Description": "Описание",
"Disable_Amount": "Деактивиране на сумата",
"Documentation": "Документация",
"DontChange": "",
"Download": "Изтегляне",
"Drag_Here_To_Delete": "Плъзнете тук, за да изтриете",
"Edit": "Редактиране",
@@ -86,9 +75,7 @@
"Export_Supported": "Поддържа се експорт",
"Export_To_ICal": "Експортиране на .ics",
"External": "Външен",
"ExternalRecipe": "",
"External_Recipe_Image": "Външно изображение на рецептата",
"FETCH_ERROR": "",
"Failure": "Неуспешно",
"Fats": "Мазнини",
"File": "Файл",
@@ -109,7 +96,6 @@
"Hide_Keywords": "Скриване на ключова дума",
"Hide_Recipes": "Скриване на рецепти",
"Hide_as_header": "Скриване като заглавка",
"Hierarchy": "",
"Icon": "Икона",
"IgnoreAccents": "",
"IgnoreAccentsHelp": "",
@@ -154,9 +140,7 @@
"Meal_Type_Required": "Изисква се вид хранене",
"Meal_Types": "Видове хранене",
"Merge": "Обединяване",
"MergeAutomateHelp": "",
"Merge_Keyword": "Обединяване на ключова дума",
"MissingProperties": "",
"Month": "Месец",
"Move": "Премести",
"MoveCategory": "Премести към: ",
@@ -177,9 +161,7 @@
"New_Unit": "Нова единица",
"Next_Day": "Следващия ден",
"Next_Period": "Следващ период",
"No": "",
"NoCategory": "Няма избрана категория.",
"NoUnit": "",
"No_ID": "Идентификатора не е намерен, не може да се изтрие.",
"No_Results": "Няма резултати",
"NotInShopping": "{food} не е в списъка ви за пазаруване.",
@@ -211,8 +193,6 @@
"Previous_Day": "Предишен ден",
"Previous_Period": "Предишен период",
"Print": "Печат",
"Private": "",
"Private_Recipe_Help": "",
"Protected": "Защитен",
"Proteins": "Протеини (белтъчини)",
"Quick actions": "Бързи действия",
@@ -227,9 +207,7 @@
"Recipes": "Рецепти",
"Recipes_In_Import": "Рецепти във вашия файл за импортиране",
"Recipes_per_page": "Рецепти на страница",
"RemoveAllType": "",
"RemoveFoodFromShopping": "Премахнете {food} от списъка си за пазаруване",
"RemoveParent": "",
"Remove_nutrition_recipe": "Изтрийте хранителните стойности от рецептата",
"Reset": "Нулиране",
"Reset_Search": "Нулиране на търсенето",
@@ -260,7 +238,6 @@
"Single": "Единичен",
"Size": "Размер",
"Sort_by_new": "Сортиране по ново",
"Space": "",
"Starting_Day": "Начален ден от седмицата",
"StartsWith": "",
"StartsWithHelp": "",
@@ -284,7 +261,6 @@
"TrigramThreshold": "",
"TrigramThresholdHelp": "",
"Type": "Тип",
"UPDATE_ERROR": "",
"Undefined": "Недефиниран",
"Unit": "Единица",
"Unit_Alias": "Псевдоним на единица",
@@ -298,7 +274,6 @@
"User": "потребител",
"View": "Изглед",
"View_Recipes": "Вижте рецепти",
"Visibility": "",
"Waiting": "Очакване",
"Warning": "Внимание",
"Warning_Delete_Supermarket_Category": "Изтриването на категория супермаркет ще изтрие и всички връзки с храни. Сигурен ли си?",
@@ -306,7 +281,6 @@
"Week": "Седмица",
"Week_Numbers": "Номера на седмиците",
"Year": "Година",
"Yes": "",
"add_keyword": "Добавяне на ключова дума",
"additional_options": "Допълнителни настройки",
"advanced": "Разширено",

View File

@@ -1,10 +1,7 @@
{
"API": "API",
"API_Browser": "",
"API_Documentation": "",
"Account": "Compte",
"Add": "Afegir",
"AddChild": "",
"AddFoodToShopping": "Afegeix {food} a la llista de la compra",
"AddToShopping": "Afegir a la llista de la compra",
"Add_Servings_to_Shopping": "Afegir {servings} racions a la compra",
@@ -27,13 +24,8 @@
"Automate": "Automatitzar",
"Automation": "Automatizació",
"Back": "Enrere",
"BatchDeleteConfirm": "",
"BatchDeleteHelp": "",
"BatchEdit": "",
"BatchEditUpdatingItemsCount": "",
"Bookmarklet": "Marcadors",
"Books": "Llibres",
"CREATE_ERROR": "",
"Calculator": "Calculadora",
"Calories": "Calories",
"Cancel": "Cancelar",
@@ -82,7 +74,6 @@
"CustomNavLogoHelp": "Pengeu una imatge per utilitzar com a logotip a la barra de navegació.",
"CustomTheme": "Tema Personalitzat",
"CustomThemeHelp": "Cancel·la els estils del tema seleccionat Carregant un fitxer CSS personalitzat.",
"DELETE_ERROR": "",
"Data_Import_Info": "Millora el teu Espai important llistes daliments, unitats i més, seleccionats per la comunitat per millorar la teva col·lecció de receptes.",
"Datatype": "Tipus de Dades",
"Date": "Data",
@@ -95,7 +86,6 @@
"DelayUntil": "Endarrerir fins",
"Delete": "Eliminar",
"DeleteShoppingConfirm": "Segur que vols eliminar tot el/la {food} de la llista de la compra?",
"DeleteSomething": "",
"Delete_All": "Eliminar tot",
"Delete_Food": "Eliminar Aliment",
"Delete_Keyword": "Esborreu paraula clau",
@@ -105,7 +95,6 @@
"Disable_Amount": "Deshabiliteu quantitat",
"Disabled": "Desactivat",
"Documentation": "Documentació",
"DontChange": "",
"Download": "Descarregar",
"Drag_Here_To_Delete": "Arrossega aquí per a eliminar",
"Edit": "Editar",
@@ -125,12 +114,10 @@
"Export_Supported": "Exportació suportada",
"Export_To_ICal": "Exportar .ics",
"External": "Extern",
"ExternalRecipe": "",
"External_Recipe_Image": "Imatge externa de la recepta",
"FDC_ID": "FDC ID",
"FDC_ID_help": "Base de dades FDC ID",
"FDC_Search": "Cerca FDC",
"FETCH_ERROR": "",
"Failure": "Error",
"Fats": "Greixos",
"File": "Arxiu",
@@ -153,7 +140,6 @@
"Hide_Keywords": "Amagueu paraula clau",
"Hide_Recipes": "Amagueu receptes",
"Hide_as_header": "Amagueu com a títol",
"Hierarchy": "",
"Hour": "Hora",
"Hours": "Hores",
"Icon": "Icona",
@@ -210,10 +196,8 @@
"Meal_Type_Required": "El tipus és obligatori",
"Meal_Types": "Tipus de menjars",
"Merge": "Unificar",
"MergeAutomateHelp": "",
"Merge_Keyword": "Fusioneu paraula clau",
"Message": "Missatge",
"MissingProperties": "",
"Month": "Mes",
"Move": "Moure",
"MoveCategory": "Moure a: ",
@@ -241,10 +225,8 @@
"New_Unit": "Nova unitat",
"Next_Day": "Següent dia",
"Next_Period": "Període següent",
"No": "",
"NoCategory": "No s'ha seleccionat categoria.",
"NoMoreUndo": "No hi ha canvis per desar.",
"NoUnit": "",
"No_ID": "No s'ha trobat l'ID, no es pot eliminar.",
"No_Results": "No hi ha resultats",
"NotInShopping": "{food} no està a la teva llista de la compra.",
@@ -282,7 +264,6 @@
"Previous_Day": "Dia Anterior",
"Previous_Period": "Període anterior",
"Print": "Imprimir",
"Private": "",
"Private_Recipe": "Recepta privada",
"Private_Recipe_Help": "Només tu i la gent amb qui l'has compartit podran veure aquesta recepta.",
"Properties": "Propietats",
@@ -304,9 +285,7 @@
"Recipes": "Receptes",
"Recipes_In_Import": "Receptes al fitxer d'importació",
"Recipes_per_page": "Receptes per pàgina",
"RemoveAllType": "",
"RemoveFoodFromShopping": "Elimina {food} de la llista de la compra",
"RemoveParent": "",
"Remove_nutrition_recipe": "Esborreu nutrició de la recepta",
"Reset": "Restablir",
"Reset_Search": "Reinicieu la cerca",
@@ -345,7 +324,6 @@
"Size": "Mida",
"Social_Authentication": "Identificació amb Xarxes Socials",
"Sort_by_new": "Ordenar a partir del més nou",
"Space": "",
"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",
@@ -376,7 +354,6 @@
"TrigramThreshold": "",
"TrigramThresholdHelp": "",
"Type": "Tipus",
"UPDATE_ERROR": "",
"Unchanged": "Sense Canvis",
"Undefined": "indefinit",
"Undo": "Desfer",
@@ -404,7 +381,6 @@
"Valid Until": "Vàlid fins",
"View": "Mostrar",
"View_Recipes": "Mostreu les receptes",
"Visibility": "",
"Waiting": "Esperant",
"Warning": "Advertència",
"Warning_Delete_Supermarket_Category": "Si suprimiu una categoria de supermercat, també se suprimiran totes les relacions alimentàries. N'estàs segur?",
@@ -413,7 +389,6 @@
"Week_Numbers": "Números de la setmana",
"Welcome": "Benvingut/da",
"Year": "Any",
"Yes": "",
"add_keyword": "Afegir Paraula Clau",
"additional_options": "Opcions addicionals",
"advanced": "Avançat",

View File

@@ -1,10 +1,7 @@
{
"API": "API",
"API_Browser": "",
"API_Documentation": "",
"Account": "Účet",
"Add": "Přidat",
"AddChild": "",
"AddFoodToShopping": "Přidat {food} na váš nákupní seznam",
"AddToShopping": "Přidat do nákupního seznamu",
"Add_Servings_to_Shopping": "Přidat {servings} porce na nákupní seznam",
@@ -27,13 +24,8 @@
"Automate": "Automatizovat",
"Automation": "Automatizace",
"Back": "Zpět",
"BatchDeleteConfirm": "",
"BatchDeleteHelp": "",
"BatchEdit": "",
"BatchEditUpdatingItemsCount": "",
"Bookmarklet": "Skript v záložce",
"Books": "Kuchařky",
"CREATE_ERROR": "",
"Calculator": "Kalkulačka",
"Calories": "Kalorie",
"Cancel": "Zrušit",
@@ -81,7 +73,6 @@
"CustomNavLogoHelp": "Nahrajte obrázek, který se má zobrazit jako logo v navigačním panelu.",
"CustomTheme": "Vlastní téma",
"CustomThemeHelp": "Přepsat styly vybraného motivu nahráním vlastního souboru CSS.",
"DELETE_ERROR": "",
"Data_Import_Info": "Rozšiřte svůj prostor o seznamy potravin, jednotek a dalších položek spravovaných komunitou, a vylepšete tak svoji sbírku receptů.",
"Datatype": "Datový typ",
"Date": "Datum",
@@ -94,7 +85,6 @@
"DelayUntil": "Odložit do",
"Delete": "Smazat",
"DeleteShoppingConfirm": "Jste si jistí, že chcete odstranit všechno {food} z nákupního seznamu?",
"DeleteSomething": "",
"Delete_All": "Smazat vše",
"Delete_Food": "Smazat potravinu",
"Delete_Keyword": "Smazat štítek",
@@ -104,7 +94,6 @@
"Disable_Amount": "Skrýt množství",
"Disabled": "Deaktivované",
"Documentation": "Dokumentace",
"DontChange": "",
"Download": "Stáhnout",
"Drag_Here_To_Delete": "Přesunutím sem smazat",
"Edit": "Upravit",
@@ -124,12 +113,10 @@
"Export_Supported": "Export podporován",
"Export_To_ICal": "Export ovat .ics",
"External": "Externí",
"ExternalRecipe": "",
"External_Recipe_Image": "Externí obrázek receptu",
"FDC_ID": "FDC ID",
"FDC_ID_help": "ID v databázi FDC",
"FDC_Search": "Vyhledávání v FDC",
"FETCH_ERROR": "",
"Failure": "Selhání",
"Fats": "Tuky",
"File": "Soubor",
@@ -152,7 +139,6 @@
"Hide_Keywords": "Skrýt štítek",
"Hide_Recipes": "Skrýt recept",
"Hide_as_header": "Skryj jako nadpis",
"Hierarchy": "",
"Hour": "Hodina",
"Hours": "Hodiny",
"Icon": "Ikona",
@@ -208,10 +194,8 @@
"Meal_Type_Required": "Druh jídla je povinný",
"Meal_Types": "Druhy jídel",
"Merge": "Spojit",
"MergeAutomateHelp": "",
"Merge_Keyword": "Sloučit štítek",
"Message": "Zpráva",
"MissingProperties": "",
"Month": "Měsíc",
"Move": "Přesunout",
"MoveCategory": "Přesunout do: ",
@@ -239,9 +223,7 @@
"New_Unit": "Nová jednotka",
"Next_Day": "Následující den",
"Next_Period": "Další období",
"No": "",
"NoCategory": "Není vybrána žádná kategorie.",
"NoUnit": "",
"No_ID": "ID nenalezeno, odstranění není možné.",
"No_Results": "Žádné výsledky",
"NotInShopping": "{food} není na vašem nákupním seznamu.",
@@ -279,7 +261,6 @@
"Previous_Day": "Předchozí den",
"Previous_Period": "Předchozí období",
"Print": "Tisk",
"Private": "",
"Private_Recipe": "Soukromý recept",
"Private_Recipe_Help": "Recept můžete zobrazit pouze vy a lidé, se kterými jej sdílíte.",
"Properties": "Nutriční vlastnosti",
@@ -301,9 +282,7 @@
"Recipes": "Recepty",
"Recipes_In_Import": "Receptů v importním souboru",
"Recipes_per_page": "Receptů na stránku",
"RemoveAllType": "",
"RemoveFoodFromShopping": "Odstranit {food} z nákupního seznamu",
"RemoveParent": "",
"Remove_nutrition_recipe": "Smazat nutriční hodnoty",
"Reset": "Resetovat",
"Reset_Search": "Zrušit filtry vyhledávání",
@@ -340,7 +319,6 @@
"Size": "Velikost",
"Social_Authentication": "Přihlašování pomocí účtů sociálních sítí",
"Sort_by_new": "Seřadit od nejnovějšího",
"Space": "",
"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",
@@ -371,7 +349,6 @@
"TrigramThreshold": "",
"TrigramThresholdHelp": "",
"Type": "Typ",
"UPDATE_ERROR": "",
"Undefined": "Neurčeno",
"Unit": "Jednotka",
"Unit_Alias": "Přezdívka jednotky",
@@ -396,7 +373,6 @@
"Valid Until": "Platné do",
"View": "Zobrazit",
"View_Recipes": "Zobrazit recepty",
"Visibility": "",
"Waiting": "Čekající",
"Warning": "Varování",
"Warning_Delete_Supermarket_Category": "Vymazáním kategorie obchodu dojde k odstranění všech vazeb na potraviny. Jste si jistí?",
@@ -405,7 +381,6 @@
"Week_Numbers": "Číslo týdne",
"Welcome": "Vítejte",
"Year": "Rok",
"Yes": "",
"add_keyword": "Přidat štítek",
"additional_options": "Rozšířené možnosti",
"advanced": "Pokročilé",

View File

@@ -1,10 +1,7 @@
{
"API": "API",
"API_Browser": "",
"API_Documentation": "",
"Account": "Bruger",
"Add": "Tilføj",
"AddChild": "",
"AddFoodToShopping": "Tilføj {food} til indkøbsliste",
"AddToShopping": "Tilføj til indkøbsliste",
"Add_Servings_to_Shopping": "Tilføj {servings} serveringer til indkøb",
@@ -27,13 +24,8 @@
"Automate": "Automatiser",
"Automation": "Automatisering",
"Back": "Tilbage",
"BatchDeleteConfirm": "",
"BatchDeleteHelp": "",
"BatchEdit": "",
"BatchEditUpdatingItemsCount": "",
"Bookmarklet": "Bogmærke",
"Books": "Bøger",
"CREATE_ERROR": "",
"Calculator": "Lommeregner",
"Calories": "Kalorier",
"Cancel": "Annuller",
@@ -82,7 +74,6 @@
"CustomNavLogoHelp": "Upload et billede til brug som navigationsbarrelogo.",
"CustomTheme": "Personaliseret tema",
"CustomThemeHelp": "Overskriv det valgte temas stil ved at uploade en personlig CSS-fil.",
"DELETE_ERROR": "",
"Data_Import_Info": "Udbyg dit Space og gør din opskriftsamling bedre ved at importere en netværkskurateret liste af ingredienser, enheder og mere.",
"Datatype": "Datatype",
"Date": "Dato",
@@ -95,7 +86,6 @@
"DelayUntil": "Udskyd indtil",
"Delete": "Slet",
"DeleteShoppingConfirm": "Er du sikker på at du vil fjerne {food} fra indkøbsliste?",
"DeleteSomething": "",
"Delete_All": "Slet alle",
"Delete_Food": "Slet mad",
"Delete_Keyword": "Slet nøgleord",
@@ -105,7 +95,6 @@
"Disable_Amount": "Deaktiver antal",
"Disabled": "Slået fra",
"Documentation": "Dokumentation",
"DontChange": "",
"Download": "Download",
"Drag_Here_To_Delete": "Træk herhen for at slette",
"Edit": "Rediger",
@@ -125,12 +114,10 @@
"Export_Supported": "Eksport understøttet",
"Export_To_ICal": "Eksporter .ics",
"External": "Ekstern",
"ExternalRecipe": "",
"External_Recipe_Image": "Eksternt billede af opskrift",
"FDC_ID": "FDC ID",
"FDC_ID_help": "FDC database ID",
"FDC_Search": "FDC søgning",
"FETCH_ERROR": "",
"Failure": "Mislykkedes",
"Fats": "Fedtstoffer",
"File": "Fil",
@@ -153,7 +140,6 @@
"Hide_Keywords": "Skjul nøgleord",
"Hide_Recipes": "Skjul opskrifter",
"Hide_as_header": "Skjul som rubrik",
"Hierarchy": "",
"Hour": "Time",
"Hours": "Timer",
"Icon": "Ikon",
@@ -210,10 +196,8 @@
"Meal_Type_Required": "Måltidstype påkrævet",
"Meal_Types": "Måltidstyper",
"Merge": "Sammenflet",
"MergeAutomateHelp": "",
"Merge_Keyword": "Sammenflet nøgleord",
"Message": "Besked",
"MissingProperties": "",
"Month": "Måned",
"Move": "Flyt",
"MoveCategory": "Flyt til: ",
@@ -241,10 +225,8 @@
"New_Unit": "Ny enhed",
"Next_Day": "Næste dag",
"Next_Period": "Næste periode",
"No": "",
"NoCategory": "Ingen kategori valgt.",
"NoMoreUndo": "Ingen ændringer at fortryde.",
"NoUnit": "",
"No_ID": "ID findes ikke, kan ikke slette.",
"No_Results": "Ingen resultater",
"NotInShopping": "{food} er ikke i din indkøbsliste.",
@@ -282,7 +264,6 @@
"Previous_Day": "Forgående dag",
"Previous_Period": "Forgående periode",
"Print": "Udskriv",
"Private": "",
"Private_Recipe": "Privat opskrift",
"Private_Recipe_Help": "Opskriften er kun synlig for dig, og dem som den er delt med.",
"Properties": "Egenskaber",
@@ -304,9 +285,7 @@
"Recipes": "Opskrifter",
"Recipes_In_Import": "Opskrifter i din importerede fil",
"Recipes_per_page": "Opskrifter pr. side",
"RemoveAllType": "",
"RemoveFoodFromShopping": "Fjern {food} fra indkøbsliste",
"RemoveParent": "",
"Remove_nutrition_recipe": "Fjern næringsindhold fra opskrift",
"Reset": "Nulstil",
"Reset_Search": "Nulstil søgning",
@@ -345,7 +324,6 @@
"Size": "Størrelse",
"Social_Authentication": "Social authenticering",
"Sort_by_new": "Sorter efter nylige",
"Space": "",
"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",
@@ -376,7 +354,6 @@
"TrigramThreshold": "",
"TrigramThresholdHelp": "",
"Type": "Type",
"UPDATE_ERROR": "",
"Unchanged": "Uændret",
"Undefined": "Ikke defineret",
"Undo": "Fortryd",
@@ -404,7 +381,6 @@
"Valid Until": "Gyldig indtil",
"View": "Vis",
"View_Recipes": "Vis opskrifter",
"Visibility": "",
"Waiting": "Vente",
"Warning": "Advarsel",
"Warning_Delete_Supermarket_Category": "At slette en supermarkedskategori vil også slette alle relationer til mad. Er du sikker?",
@@ -413,7 +389,6 @@
"Week_Numbers": "Ugenumre",
"Welcome": "Velkommen",
"Year": "År",
"Yes": "",
"add_keyword": "Tilføj nøgleord",
"additional_options": "Yderligere indstillinger",
"advanced": "Avanceret",

Some files were not shown because too many files have changed in this diff Show More