Compare commits

..

1 Commits

Author SHA1 Message Date
vabene1111
bc82049f42 work in progress, playing around 2022-10-02 16:39:56 +02:00
267 changed files with 14396 additions and 26619 deletions

View File

@@ -2,10 +2,6 @@
# when unset: 1 (true) - dont unset this, just for development
DEBUG=0
SQL_DEBUG=0
DEBUG_TOOLBAR=0
# Gunicorn log level for debugging (default value is "info" when unset)
# (see https://docs.gunicorn.org/en/stable/settings.html#loglevel for available settings)
# GUNICORN_LOG_LEVEL="debug"
# HTTP port to bind to
# TANDOOR_PORT=8080
@@ -101,7 +97,7 @@ GUNICORN_MEDIA=0
# ACCOUNT_EMAIL_SUBJECT_PREFIX=
# allow authentication via reverse proxy (e.g. authelia), leave off if you dont know what you are doing
# see docs for more information https://docs.tandoor.dev/features/authentication/
# see docs for more information https://vabene1111.github.io/recipes/features/authentication/
# when unset: 0 (false)
REVERSE_PROXY_AUTH=0
@@ -130,7 +126,7 @@ REVERSE_PROXY_AUTH=0
# ENABLE_METRICS=0
# allows you to setup OAuth providers
# see docs for more information https://docs.tandoor.dev/features/authentication/
# see docs for more information https://vabene1111.github.io/recipes/features/authentication/
# SOCIAL_PROVIDERS = allauth.socialaccount.providers.github, allauth.socialaccount.providers.nextcloud,
# Should a newly created user from a social provider get assigned to the default space and given permission by default ?
@@ -161,7 +157,6 @@ REVERSE_PROXY_AUTH=0
#AUTH_LDAP_BIND_PASSWORD=
#AUTH_LDAP_USER_SEARCH_BASE_DN=
#AUTH_LDAP_TLS_CACERTFILE=
#AUTH_LDAP_START_TLS=
# Enables exporting PDF (see export docs)
# Disabled by default, uncomment to enable

View File

@@ -14,8 +14,3 @@ updates:
directory: "/vue/"
schedule:
interval: "monthly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"

View File

@@ -1,120 +0,0 @@
name: Build Docker Container with open data plugin installed
on: push
jobs:
build-container:
name: Build ${{ matrix.name }} Container
runs-on: ubuntu-latest
if: github.repository_owner == 'TandoorRecipes'
continue-on-error: ${{ matrix.continue-on-error }}
permissions:
contents: read
packages: write
strategy:
matrix:
include:
# Standard build config
- name: Standard
dockerfile: Dockerfile
platforms: linux/amd64,linux/arm64
suffix: ""
continue-on-error: false
steps:
- uses: actions/checkout@v3
- name: Get version number
id: get_version
run: |
if [[ "$GITHUB_REF" = refs/tags/* ]]; then
echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
elif [[ "$GITHUB_REF" = refs/heads/beta ]]; then
echo VERSION=beta >> $GITHUB_OUTPUT
else
echo VERSION=develop >> $GITHUB_OUTPUT
fi
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.2
with:
path: recipes/version.py
contents: |
VERSION_NUMBER = '${{ steps.get_version.outputs.VERSION }}-open-data'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# clone open data plugin
- name: clone open data plugin repo
uses: actions/checkout@master
with:
repository: TandoorRecipes/open_data_plugin
ref: master
path: ./recipes/plugins/open_data_plugin
# Build Vue frontend
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: yarn
cache-dependency-path: vue/yarn.lock
- name: Install dependencies
working-directory: ./vue
run: yarn install --frozen-lockfile
- name: Build dependencies
working-directory: ./vue
run: yarn build
- name: Setup Open Data Plugin Links
working-directory: ./recipes/plugins/open_data_plugin
run: python setup_repo.py
- name: Build Open Data Frontend
working-directory: ./recipes/plugins/open_data_plugin/vue
run: yarn build
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
if: github.secret_source == 'Actions'
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
if: github.secret_source == 'Actions'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
vabene1111/recipes
ghcr.io/TandoorRecipes/recipes
flavor: |
latest=false
suffix=${{ matrix.suffix }}
tags: |
type=raw,value=latest,suffix=-open-data-plugin,enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=semver,suffix=-open-data-plugin,pattern={{version}}
type=semver,suffix=-open-data-plugin,pattern={{major}}.{{minor}}
type=semver,suffix=-open-data-plugin,pattern={{major}}
type=ref,suffix=-open-data-plugin,event=branch
- name: Build and Push
uses: docker/build-push-action@v4
with:
context: .
file: ${{ matrix.dockerfile }}
pull: true
push: ${{ github.secret_source == 'Actions' }}
platforms: ${{ matrix.platforms }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,142 +0,0 @@
name: Build Docker Container
on: push
jobs:
build-container:
name: Build ${{ matrix.name }} Container
runs-on: ubuntu-latest
if: github.repository_owner == 'TandoorRecipes'
continue-on-error: ${{ matrix.continue-on-error }}
permissions:
contents: read
packages: write
strategy:
matrix:
include:
# Standard build config
- name: Standard
dockerfile: Dockerfile
platforms: linux/amd64,linux/arm64
suffix: ""
continue-on-error: false
# Raspi build config
- name: Raspi
dockerfile: Dockerfile-raspi
platforms: linux/arm/v7
suffix: "-raspi"
continue-on-error: true
steps:
- uses: actions/checkout@v3
- name: Get version number
id: get_version
run: |
if [[ "$GITHUB_REF" = refs/tags/* ]]; then
echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
elif [[ "$GITHUB_REF" = refs/heads/beta ]]; then
echo VERSION=beta >> $GITHUB_OUTPUT
else
echo VERSION=develop >> $GITHUB_OUTPUT
fi
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.2
with:
path: recipes/version.py
contents: |
VERSION_NUMBER = '${{ steps.get_version.outputs.VERSION }}'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# Build Vue frontend
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: yarn
cache-dependency-path: vue/yarn.lock
- name: Install dependencies
working-directory: ./vue
run: yarn install --frozen-lockfile
- name: Build dependencies
working-directory: ./vue
run: yarn build
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
if: github.secret_source == 'Actions'
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
if: github.secret_source == 'Actions'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
vabene1111/recipes
ghcr.io/TandoorRecipes/recipes
flavor: |
latest=false
suffix=${{ matrix.suffix }}
tags: |
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=branch
- name: Build and Push
uses: docker/build-push-action@v4
with:
context: .
file: ${{ matrix.dockerfile }}
pull: true
push: ${{ github.secret_source == 'Actions' }}
platforms: ${{ matrix.platforms }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
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.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'
steps:
# Send beta discord notification
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_BETA_WEBHOOK }}
uses: Ilshidur/action-discord@0.3.2
with:
args: '🚀 The BETA Image has been updated! 🥳'

View File

@@ -1,6 +1,6 @@
name: Continuous Integration
on: [push, pull_request]
on: [push]
jobs:
build:
@@ -12,15 +12,15 @@ jobs:
python-version: ['3.10']
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v1
- name: Set up Python 3.10
uses: actions/setup-python@v4
uses: actions/setup-python@v1
with:
python-version: '3.10'
# Build Vue frontend
- uses: actions/setup-node@v3
- uses: actions/setup-node@v2
with:
node-version: '18'
node-version: '14'
- name: Install Vue dependencies
working-directory: ./vue
run: yarn install
@@ -30,7 +30,7 @@ jobs:
- name: Install Django dependencies
run: |
sudo apt-get -y update
sudo apt-get install -y libsasl2-dev python3-dev libldap2-dev libssl-dev
sudo apt-get install -y libsasl2-dev python-dev libldap2-dev libssl-dev
python -m pip install --upgrade pip
pip install -r requirements.txt
python3 manage.py collectstatic --noinput

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -25,7 +25,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v1
# Override language selection by uncommenting this and choosing your languages
with:
languages: python, javascript
@@ -47,6 +47,6 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v1
with:
languages: javascript, python

View File

@@ -0,0 +1,48 @@
name: publish beta raspi image docker
on:
push:
branches:
- 'beta'
jobs:
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.0
with:
path: recipes/version.py
contents: |
VERSION_NUMBER = 'beta'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# Build Vue frontend
- uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install dependencies
working-directory: ./vue
run: yarn install
- name: Build dependencies
working-directory: ./vue
run: yarn build
# Build container
- name: Build and publish image
uses: ilteoood/docker_buildx@master
with:
publish: true
imageName: vabene1111/recipes
tag: beta-raspi
dockerFile: Dockerfile-raspi
platform: linux/arm/v7
dockerUser: ${{ secrets.DOCKER_USERNAME }}
dockerPassword: ${{ secrets.DOCKER_PASSWORD }}
# Send discord notification
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_BETA_WEBHOOK }}
uses: Ilshidur/action-discord@0.3.2
with:
args: '🚀 The BETA Image has been updated! 🥳'

View File

@@ -0,0 +1,47 @@
name: publish beta image docker
on:
push:
branches:
- 'beta'
jobs:
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.0
with:
path: recipes/version.py
contents: |
VERSION_NUMBER = 'beta'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# Build Vue frontend
- uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install dependencies
working-directory: ./vue
run: yarn install
- name: Build dependencies
working-directory: ./vue
run: yarn build
# Build container
- name: Build and publish image
uses: ilteoood/docker_buildx@master
with:
publish: true
imageName: vabene1111/recipes
tag: beta
platform: linux/amd64,linux/arm64
dockerUser: ${{ secrets.DOCKER_USERNAME }}
dockerPassword: ${{ secrets.DOCKER_PASSWORD }}
# Send discord notification
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_BETA_WEBHOOK }}
uses: Ilshidur/action-discord@0.3.2
with:
args: '🚀 The BETA Image has been updated! 🥳'

View File

@@ -0,0 +1,42 @@
name: publish dev image docker
on:
push:
branches:
- '*'
- '*/*'
- '!master'
jobs:
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.0
with:
path: recipes/version.py
contents: |
VERSION_NUMBER = 'develop'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# Build Vue frontend
- uses: actions/setup-node@v2
with:
node-version: '14'
- name: Clear Cache
working-directory: ./vue
run: yarn cache clean --all
- name: Install dependencies
working-directory: ./vue
run: yarn install
- name: Build dependencies
working-directory: ./vue
run: yarn build
# Build container
- name: Publish to Registry
uses: elgohr/Publish-Docker-Github-Action@2.13
with:
name: vabene1111/recipes
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

View File

@@ -0,0 +1,45 @@
name: publish latest raspi image docker
on:
push:
tags:
- '*'
jobs:
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Get version number
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}-raspi
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.0
with:
path: recipes/version.py
contents: |
VERSION_NUMBER = '${{ steps.get_version.outputs.VERSION }}-raspi'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# Build Vue frontend
- uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install dependencies
working-directory: ./vue
run: yarn install
- name: Build dependencies
working-directory: ./vue
run: yarn build
# Build container
- name: Build and publish image
uses: ilteoood/docker_buildx@master
with:
publish: true
imageName: vabene1111/recipes
dockerFile: Dockerfile-raspi
platform: linux/arm/v7
tag: latest-raspi
dockerUser: ${{ secrets.DOCKER_USERNAME }}
dockerPassword: ${{ secrets.DOCKER_PASSWORD }}

View File

@@ -0,0 +1,44 @@
name: publish latest image docker
on:
push:
tags:
- '*'
jobs:
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Get version number
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.0
with:
path: recipes/version.py
contents: |
VERSION_NUMBER = '${{ steps.get_version.outputs.VERSION }}'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# Build Vue frontend
- uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install dependencies
working-directory: ./vue
run: yarn install
- name: Build dependencies
working-directory: ./vue
run: yarn build
# Build container
- name: Build and publish image
uses: ilteoood/docker_buildx@master
with:
publish: true
imageName: vabene1111/recipes
platform: linux/amd64,linux/arm64
tag: latest
dockerUser: ${{ secrets.DOCKER_USERNAME }}
dockerPassword: ${{ secrets.DOCKER_PASSWORD }}

View File

@@ -0,0 +1,47 @@
name: publish tagged raspi release docker
on:
release:
types: [published]
jobs:
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
name: Build image job
steps:
- name: Checkout master
uses: actions/checkout@master
- name: Get version number
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.0
with:
path: recipes/version.py
contents: |
VERSION_NUMBER = '${{ steps.get_version.outputs.VERSION }}'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# Build Vue frontend
- uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install dependencies
working-directory: ./vue
run: yarn install
- name: Build dependencies
working-directory: ./vue
run: yarn build
# Build container
- name: Build and publish image
uses: ilteoood/docker_buildx@master
with:
publish: true
imageName: vabene1111/recipes
dockerFile: Dockerfile-raspi
platform: linux/arm/v7
tag: ${{ steps.get_version.outputs.VERSION }}-raspi
dockerUser: ${{ secrets.DOCKER_USERNAME }}
dockerPassword: ${{ secrets.DOCKER_PASSWORD }}

View File

@@ -0,0 +1,53 @@
name: publish tagged release docker
on:
release:
types: [published]
jobs:
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
name: Build image job
steps:
- name: Checkout master
uses: actions/checkout@master
- name: Get version number
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.0
with:
path: recipes/version.py
contents: |
VERSION_NUMBER = '${{ steps.get_version.outputs.VERSION }}'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# Build Vue frontend
- uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install dependencies
working-directory: ./vue
run: yarn install
- name: Build dependencies
working-directory: ./vue
run: yarn build
# Build container
- name: Build and publish image
uses: ilteoood/docker_buildx@master
with:
publish: true
imageName: vabene1111/recipes
platform: linux/amd64,linux/arm64
tag: ${{ steps.get_version.outputs.VERSION }}
dockerUser: ${{ secrets.DOCKER_USERNAME }}
dockerPassword: ${{ secrets.DOCKER_PASSWORD }}
# Send discord notification
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
uses: Ilshidur/action-discord@0.3.2
with:
args: '🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of tandoor has been released 🥳 Check it out https://github.com/vabene1111/recipes/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}'

View File

@@ -9,8 +9,8 @@ jobs:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: 3.x
- run: pip install mkdocs-material mkdocs-include-markdown-plugin

2
.gitignore vendored
View File

@@ -78,11 +78,9 @@ postgresql/
/docker-compose.override.yml
vue/node_modules
plugins
.vscode/
vetur.config.js
cookbook/static/vue
vue/webpack-stats.json
cookbook/templates/sw.js
.prettierignore
vue/.yarn

View File

@@ -1,8 +0,0 @@
<component name="ProjectDictionaryState">
<dictionary name="vaben">
<words>
<w>pinia</w>
<w>selfhosted</w>
</words>
</dictionary>
</component>

View File

@@ -3,6 +3,8 @@
<words>
<w>autosync</w>
<w>chowdown</w>
<w>cookingmachine</w>
<w>cookit</w>
<w>csrftoken</w>
<w>gunicorn</w>
<w>ical</w>

2
.idea/recipes.iml generated
View File

@@ -18,7 +18,7 @@
<excludeFolder url="file://$MODULE_DIR$/staticfiles" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="jdk" jdkName="Python 3.9 (recipes)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="TemplatesService">

View File

@@ -6,4 +6,5 @@ Since this software is still considered beta/WIP support is always only given fo
## Reporting a Vulnerability
Please use GitHub Security Advisories to report any kind of security vulnerabilities.
Please open a normal public issue if you have any security related concerns. If you feel like the issue should not be discussed in
public just open a generic issue and we will discuss further communication there (since GitHub does not allow everyone to create a security advisory :/).

View File

@@ -4,7 +4,6 @@ source venv/bin/activate
TANDOOR_PORT="${TANDOOR_PORT:-8080}"
GUNICORN_WORKERS="${GUNICORN_WORKERS:-3}"
GUNICORN_THREADS="${GUNICORN_THREADS:-2}"
GUNICORN_LOG_LEVEL="${GUNICORN_LOG_LEVEL:-'info'}"
NGINX_CONF_FILE=/opt/recipes/nginx/conf.d/Recipes.conf
display_warning() {
@@ -66,4 +65,4 @@ echo "Done"
chmod -R 755 /opt/recipes/mediafiles
exec gunicorn -b :$TANDOOR_PORT --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
exec gunicorn -b :$TANDOOR_PORT --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level INFO recipes.wsgi

View File

@@ -10,13 +10,12 @@ from treebeard.forms import movenodeform_factory
from cookbook.managers import DICTIONARY
from .models import (Automation, BookmarkletImport, Comment, CookLog, Food, FoodInheritField,
ImportLog, Ingredient, InviteLink, Keyword, MealPlan, MealType,
NutritionInformation, Property, PropertyType, Recipe, RecipeBook,
RecipeBookEntry, RecipeImport, SearchPreference, ShareLink, ShoppingList,
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, Supermarket,
SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, TelegramBot,
Unit, UnitConversion, UserFile, UserPreference, UserSpace, ViewLog)
from .models import (BookmarkletImport, Comment, CookLog, Food, FoodInheritField, ImportLog,
Ingredient, InviteLink, Keyword, MealPlan, MealType, NutritionInformation,
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
TelegramBot, Unit, UserFile, UserPreference, ViewLog, Automation, UserSpace)
class CustomUserAdmin(UserAdmin):
@@ -33,7 +32,7 @@ admin.site.unregister(Group)
@admin.action(description='Delete all data from a space')
def delete_space_action(modeladmin, request, queryset):
for space in queryset:
space.safe_delete()
space.save()
class SpaceAdmin(admin.ModelAdmin):
@@ -151,16 +150,9 @@ class KeywordAdmin(TreeAdmin):
admin.site.register(Keyword, KeywordAdmin)
@admin.action(description='Delete Steps not part of a Recipe.')
def delete_unattached_steps(modeladmin, request, queryset):
with scopes_disabled():
Step.objects.filter(recipe=None).delete()
class StepAdmin(admin.ModelAdmin):
list_display = ('name', 'order',)
search_fields = ('name',)
actions = [delete_unattached_steps]
admin.site.register(Step, StepAdmin)
@@ -209,24 +201,9 @@ class FoodAdmin(TreeAdmin):
admin.site.register(Food, FoodAdmin)
class UnitConversionAdmin(admin.ModelAdmin):
list_display = ('base_amount', 'base_unit', 'food', 'converted_amount', 'converted_unit')
search_fields = ('food__name', 'unit__name')
admin.site.register(UnitConversion, UnitConversionAdmin)
@admin.action(description='Delete Ingredients not part of a Recipe.')
def delete_unattached_ingredients(modeladmin, request, queryset):
with scopes_disabled():
Ingredient.objects.filter(step__recipe=None).delete()
class IngredientAdmin(admin.ModelAdmin):
list_display = ('food', 'amount', 'unit')
search_fields = ('food__name', 'unit__name')
actions = [delete_unattached_ingredients]
admin.site.register(Ingredient, IngredientAdmin)
@@ -342,20 +319,6 @@ class ShareLinkAdmin(admin.ModelAdmin):
admin.site.register(ShareLink, ShareLinkAdmin)
class PropertyTypeAdmin(admin.ModelAdmin):
list_display = ('id', 'name')
admin.site.register(PropertyType, PropertyTypeAdmin)
class PropertyAdmin(admin.ModelAdmin):
list_display = ('property_amount', 'property_type')
admin.site.register(Property, PropertyAdmin)
class NutritionInformationAdmin(admin.ModelAdmin):
list_display = ('id',)

View File

@@ -0,0 +1,116 @@
import json
import random
from datetime import timedelta
import requests
from django.utils.timezone import now
from cookbook.models import CookingMachine
# Tandoor is not affiliated in any way or form with the holders of Trademark or other right associated with the mentioned names. All mentioned protected names are purely used to identify to the user a certain device or integration.
class HomeConnectCookit:
AUTH_AUTHORIZE_URL = 'https://api.home-connect.com/security/oauth/authorize'
AUTH_TOKEN_URL = 'https://api.home-connect.com/security/oauth/token'
AUTH_REFRESH_URL = 'https://api.home-connect.com/security/oauth/token'
RECIPE_API_URL = 'https://prod.reu.rest.homeconnectegw.com/user-generated-recipes/server/api/v1/recipes'
IMAGE_API_URL = 'https://prod.reu.rest.homeconnectegw.com/user-generated-recipes/server/api/v1/images'
CLIENT_ID = '' # TODO load from .env settings
_CLIENT_SECRET = '' # TODO load from .env settings
_cooking_machine = None
def __init__(self, cooking_machine):
self._cooking_machine = cooking_machine
def get_auth_link(self):
return f"{self.AUTH_AUTHORIZE_URL}?client_id={self.CLIENT_ID}&response_type=code&scope=IdentifyAppliance%20Settings&state={random.randint(100000, 999999)}"
def _validate_token(self):
if self._cooking_machine.access_token is None and self._cooking_machine.refresh_token is None:
return False # user needs to login
elif self._cooking_machine.access_token_expiry < now() + timedelta(minutes=10):
return False # refresh token
def _refresh_access_token(self):
token_response = requests.post(self.AUTH_REFRESH_URL, {
'grant_type': 'refresh_token',
'refresh_token': self._cooking_machine.refresh_token,
'client_secret': self._CLIENT_SECRET,
})
if token_response.status_code == 200:
token_response_body = json.loads(token_response.content)
self._cooking_machine.access_token = token_response_body['access_token']
self._cooking_machine.access_token_expiry = now() + timedelta(seconds=(token_response_body['expires_in'] - (60 * 10)))
self._cooking_machine.refresh_token = token_response_body['refresh_token']
self._cooking_machine.refresh_token_expiry = now() + timedelta(days=58)
self._cooking_machine.save()
def get_access_token(self, code):
token_response = requests.post(self.AUTH_TOKEN_URL, {
'grant_type': 'authorization_code',
'code': code,
'client_id': self.CLIENT_ID,
'client_secret': self._CLIENT_SECRET,
})
if token_response.status_code == 200:
token_response_body = json.loads(token_response.content)
self._cooking_machine.access_token = token_response_body['access_token']
self._cooking_machine.access_token_expiry = now() + timedelta(seconds=(token_response_body['expires_in'] - (60 * 10)))
self._cooking_machine.refresh_token = token_response_body['refresh_token']
self._cooking_machine.refresh_token_expiry = now() + timedelta(days=58)
self._cooking_machine.save()
def _get_default_headers(self):
auth_token = ''
return {
'authorization': f'Bearer {self._cooking_machine.access_token}',
'accept-language': "en-US",
"referer": "https://prod.reu.rest.homeconnectegw.com/user-generated- recipes/client/editor/recipedetails",
"user-agent": "Mozilla/5.0 (Linux; Android 10; Android SDK built for x86 Build/QSR1.210802.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/74.0.3729.185 Mobile Safari/537.36",
"x-requested-with": "com.bshg.homeconnect.android.release"
}
def push_recipe(self, recipe):
data = {
"title": recipe.name,
"description": recipe.description,
"ingredients": [],
"steps": [],
"durations": {
"totalTime": (recipe.working_time + recipe.waiting_time) * 60,
"cookingTime": 0, # recipe.waiting_time * 60, #TODO cooking time must be sum of step duration attribute, otherwise creation fails
"preparationTime": recipe.working_time * 60,
},
"keywords": [],
"servings": {
"amount": int(recipe.servings),
"unit": 'portion', # required
},
"servingTipsStep": {
"textInstructions": "Serve"
},
"complexityLevel": "medium",
"image": {
"url": "https://media3.bsh-group.com/Recipes/800x480/17062805_210217_My-own-recipe_Picture_188ppi.jpg",
"mimeType": "image/jpeg"
# TODO add image upload
},
"accessories": [], # TODO add once tandoor supports tools
"legalDisclaimerApproved": True # TODO force user to approve disclaimer
}
for step in recipe.steps.all():
data['steps'].append({
"textInstructions": step.instruction
})
for i in step.ingredients.all():
data['ingredients'].append({
"amount": int(i.amount),
"unit": i.unit.name,
"name": i.food.name,
})
# TODO create synced recipe
response = requests.post(f'{self.RECIPE_API_URL}', json=data, headers=self._get_default_headers())
return response

View File

@@ -154,7 +154,6 @@ class ImportExportBase(forms.Form):
COOKBOOKAPP = 'COOKBOOKAPP'
COPYMETHAT = 'COPYMETHAT'
COOKMATE = 'COOKMATE'
REZEPTSUITEDE = 'REZEPTSUITEDE'
PDF = 'PDF'
type = forms.ChoiceField(choices=(
@@ -163,29 +162,12 @@ class ImportExportBase(forms.Form):
(PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'),
(MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'),
(PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), (MELARECIPES, 'Melarecipes'),
(COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de')
(COOKMATE, 'Cookmate')
))
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
def __init__(self, *args, **kwargs):
kwargs.setdefault("widget", MultipleFileInput())
super().__init__(*args, **kwargs)
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
result = [single_file_clean(d, initial) for d in data]
else:
result = single_file_clean(data, initial)
return result
class ImportForm(ImportExportBase):
files = MultipleFileField(required=True)
files = forms.FileField(required=True, widget=forms.ClearableFileInput(attrs={'multiple': True}))
duplicates = forms.BooleanField(help_text=_(
'To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'),
required=False)
@@ -551,13 +533,11 @@ class SpacePreferenceForm(forms.ModelForm):
class Meta:
model = Space
fields = ('food_inherit', 'reset_food_inherit', 'show_facet_count', 'use_plural')
fields = ('food_inherit', 'reset_food_inherit', 'show_facet_count')
help_texts = {
'food_inherit': _('Fields on food that should be inherited by default.'),
'show_facet_count': _('Show recipe counts on search filters'),
'use_plural': _('Use the plural form for units and food inside this space.'),
}
'show_facet_count': _('Show recipe counts on search filters'), }
widgets = {
'food_inherit': MultiSelectWidget

View File

@@ -1,11 +0,0 @@
class CacheHelper:
space = None
BASE_UNITS_CACHE_KEY = None
PROPERTY_TYPE_CACHE_KEY = None
def __init__(self, space):
self.space = space
self.BASE_UNITS_CACHE_KEY = f'SPACE_{space.id}_BASE_UNITS'
self.PROPERTY_TYPE_CACHE_KEY = f'SPACE_{space.id}_PROPERTY_TYPES'

View File

@@ -40,12 +40,7 @@ def get_filetype(name):
# TODO also add env variable to define which images sizes should be compressed
# filetype argument can not be optional, otherwise this function will treat all images as if they were a jpeg
# Because it's no longer optional, no reason to return it
def handle_image(request, image_object, filetype):
try:
Image.open(image_object).verify()
except Exception:
return None
def handle_image(request, image_object, filetype):
if (image_object.size / 1000) > 500: # if larger than 500 kb compress
if filetype == '.jpeg' or filetype == '.jpg':
return rescale_image_jpeg(image_object)

View File

@@ -28,7 +28,7 @@ class IngredientParser:
self.food_aliases = c
caches['default'].touch(FOOD_CACHE_KEY, 30)
else:
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').order_by('order').all():
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').all():
self.food_aliases[a.param_1] = a.param_2
caches['default'].set(FOOD_CACHE_KEY, self.food_aliases, 30)
@@ -37,7 +37,7 @@ class IngredientParser:
self.unit_aliases = c
caches['default'].touch(UNIT_CACHE_KEY, 30)
else:
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').order_by('order').all():
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').all():
self.unit_aliases[a.param_1] = a.param_2
caches['default'].set(UNIT_CACHE_KEY, self.unit_aliases, 30)
else:
@@ -59,7 +59,7 @@ class IngredientParser:
except KeyError:
return food
else:
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1=food, disabled=False).order_by('order').first():
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1=food, disabled=False).first():
return automation.param_2
return food
@@ -78,7 +78,7 @@ class IngredientParser:
except KeyError:
return unit
else:
if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1=unit, disabled=False).order_by('order').first():
if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1=unit, disabled=False).first():
return automation.param_2
return unit
@@ -126,8 +126,6 @@ class IngredientParser:
amount = 0
unit = None
note = ''
if x.strip() == '':
return amount, unit, note
did_check_frac = False
end = 0
@@ -237,14 +235,6 @@ class IngredientParser:
# leading spaces before commas result in extra tokens, clean them out
ingredient = ingredient.replace(' ,', ',')
# handle "(from) - (to)" amounts by using the minimum amount and adding the range to the description
# "10.5 - 200 g XYZ" => "100 g XYZ (10.5 - 200)"
ingredient = re.sub("^(\d+|\d+[\\.,]\d+) - (\d+|\d+[\\.,]\d+) (.*)", "\\1 \\3 (\\1 - \\2)", ingredient)
# if amount and unit are connected add space in between
if re.match('([0-9])+([A-z])+\s', ingredient):
ingredient = re.sub(r'(?<=([a-z])|\d)(?=(?(1)\d|[a-z]))', ' ', ingredient)
tokens = ingredient.split() # split at each space into tokens
if len(tokens) == 1:
# there only is one argument, that must be the food

View File

@@ -35,7 +35,6 @@ Negative examples:
u'<p>del.icio.us</p>'
"""
from xml.etree.ElementTree import Element
import markdown
@@ -65,7 +64,7 @@ class UrlizePattern(markdown.inlinepatterns.Pattern):
else:
url = 'http://' + url
el = Element("a")
el = markdown.util.etree.Element("a")
el.set('href', url)
el.text = markdown.util.AtomicString(text)
return el

View File

@@ -1,214 +0,0 @@
from django.db.models import Q
from cookbook.models import Unit, SupermarketCategory, Property, PropertyType, Supermarket, SupermarketCategoryRelation, Food, Automation, UnitConversion, FoodProperty
class OpenDataImporter:
request = None
data = {}
slug_id_cache = {}
update_existing = False
use_metric = True
def __init__(self, request, data, update_existing=False, use_metric=True):
self.request = request
self.data = data
self.update_existing = update_existing
self.use_metric = use_metric
def _update_slug_cache(self, object_class, datatype):
self.slug_id_cache[datatype] = dict(object_class.objects.filter(space=self.request.space, open_data_slug__isnull=False).values_list('open_data_slug', 'id', ))
def import_units(self):
datatype = 'unit'
insert_list = []
for u in list(self.data[datatype].keys()):
insert_list.append(Unit(
name=self.data[datatype][u]['name'],
plural_name=self.data[datatype][u]['plural_name'],
base_unit=self.data[datatype][u]['base_unit'] if self.data[datatype][u]['base_unit'] != '' else None,
open_data_slug=u,
space=self.request.space
))
if self.update_existing:
return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('name', 'plural_name', 'base_unit', 'open_data_slug'), unique_fields=('space', 'name',))
else:
return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
def import_category(self):
datatype = 'category'
insert_list = []
for k in list(self.data[datatype].keys()):
insert_list.append(SupermarketCategory(
name=self.data[datatype][k]['name'],
open_data_slug=k,
space=self.request.space
))
return SupermarketCategory.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
def import_property(self):
datatype = 'property'
insert_list = []
for k in list(self.data[datatype].keys()):
insert_list.append(PropertyType(
name=self.data[datatype][k]['name'],
unit=self.data[datatype][k]['unit'],
open_data_slug=k,
space=self.request.space
))
return PropertyType.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
def import_supermarket(self):
datatype = 'store'
self._update_slug_cache(SupermarketCategory, 'category')
insert_list = []
for k in list(self.data[datatype].keys()):
insert_list.append(Supermarket(
name=self.data[datatype][k]['name'],
open_data_slug=k,
space=self.request.space
))
# always add open data slug if matching supermarket is found, otherwise relation might fail
supermarkets = Supermarket.objects.bulk_create(insert_list, unique_fields=('space', 'name',), update_conflicts=True, update_fields=('open_data_slug',))
self._update_slug_cache(Supermarket, 'store')
insert_list = []
for k in list(self.data[datatype].keys()):
relations = []
order = 0
for c in self.data[datatype][k]['categories']:
relations.append(
SupermarketCategoryRelation(
supermarket_id=self.slug_id_cache[datatype][k],
category_id=self.slug_id_cache['category'][c],
order=order,
)
)
order += 1
SupermarketCategoryRelation.objects.bulk_create(relations, ignore_conflicts=True, unique_fields=('supermarket', 'category',))
return supermarkets
def import_food(self):
identifier_list = []
datatype = 'food'
for k in list(self.data[datatype].keys()):
identifier_list.append(self.data[datatype][k]['name'])
identifier_list.append(self.data[datatype][k]['plural_name'])
existing_objects_flat = []
existing_objects = {}
for f in Food.objects.filter(space=self.request.space).filter(name__in=identifier_list).values_list('id', 'name', 'plural_name'):
existing_objects_flat.append(f[1])
existing_objects_flat.append(f[2])
existing_objects[f[1]] = f
existing_objects[f[2]] = f
self._update_slug_cache(Unit, 'unit')
self._update_slug_cache(PropertyType, 'property')
# pref_unit_key = 'preferred_unit_metric'
# pref_shopping_unit_key = 'preferred_packaging_unit_metric'
# if not self.use_metric:
# pref_unit_key = 'preferred_unit_imperial'
# pref_shopping_unit_key = 'preferred_packaging_unit_imperial'
insert_list = []
update_list = []
update_field_list = []
for k in list(self.data[datatype].keys()):
if not (self.data[datatype][k]['name'] in existing_objects_flat or self.data[datatype][k]['plural_name'] in existing_objects_flat):
insert_list.append({'data': {
'name': self.data[datatype][k]['name'],
'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
# 'preferred_unit_id': self.slug_id_cache['unit'][self.data[datatype][k][pref_unit_key]],
# 'preferred_shopping_unit_id': self.slug_id_cache['unit'][self.data[datatype][k][pref_shopping_unit_key]],
'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_category']],
'fdc_id': self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None,
'open_data_slug': k,
'space': self.request.space.id,
}})
else:
if self.data[datatype][k]['name'] in existing_objects:
existing_food_id = existing_objects[self.data[datatype][k]['name']][0]
else:
existing_food_id = existing_objects[self.data[datatype][k]['plural_name']][0]
if self.update_existing:
update_field_list = ['name', 'plural_name', 'preferred_unit_id', 'preferred_shopping_unit_id', 'supermarket_category_id', 'fdc_id', 'open_data_slug', ]
update_list.append(Food(
id=existing_food_id,
name=self.data[datatype][k]['name'],
plural_name=self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
# preferred_unit_id=self.slug_id_cache['unit'][self.data[datatype][k][pref_unit_key]],
# preferred_shopping_unit_id=self.slug_id_cache['unit'][self.data[datatype][k][pref_shopping_unit_key]],
supermarket_category_id=self.slug_id_cache['category'][self.data[datatype][k]['store_category']],
fdc_id=self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None,
open_data_slug=k,
))
else:
update_field_list = ['open_data_slug', ]
update_list.append(Food(id=existing_food_id, open_data_slug=k, ))
Food.load_bulk(insert_list, None)
if len(update_list) > 0:
Food.objects.bulk_update(update_list, update_field_list)
self._update_slug_cache(Food, 'food')
food_property_list = []
alias_list = []
for k in list(self.data[datatype].keys()):
for fp in self.data[datatype][k]['properties']['type_values']:
food_property_list.append(Property(
property_type_id=self.slug_id_cache['property'][fp['property_type']],
property_amount=fp['property_value'],
import_food_id=self.slug_id_cache['food'][k],
space=self.request.space,
))
# for a in self.data[datatype][k]['alias']:
# alias_list.append(Automation(
# param_1=a,
# param_2=self.data[datatype][k]['name'],
# space=self.request.space,
# created_by=self.request.user,
# ))
Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',))
property_food_relation_list = []
for p in Property.objects.filter(space=self.request.space, import_food_id__isnull=False).values_list('import_food_id', 'id', ):
property_food_relation_list.append(Food.properties.through(food_id=p[0], property_id=p[1]))
FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',))
# Automation.objects.bulk_create(alias_list, ignore_conflicts=True, unique_fields=('space', 'param_1', 'param_2',))
return insert_list + update_list
def import_conversion(self):
datatype = 'conversion'
insert_list = []
for k in list(self.data[datatype].keys()):
insert_list.append(UnitConversion(
base_amount=self.data[datatype][k]['base_amount'],
base_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['base_unit']],
converted_amount=self.data[datatype][k]['converted_amount'],
converted_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['converted_unit']],
food_id=self.slug_id_cache['food'][self.data[datatype][k]['food']],
open_data_slug=k,
space=self.request.space,
created_by=self.request.user,
))
return UnitConversion.objects.bulk_create(insert_list, ignore_conflicts=True, unique_fields=('space', 'base_unit', 'converted_unit', 'food', 'open_data_slug'))

View File

@@ -123,7 +123,7 @@ def share_link_valid(recipe, share):
return c
if link := ShareLink.objects.filter(recipe=recipe, uuid=share, abuse_blocked=False).first():
if 0 < settings.SHARING_LIMIT < link.request_count and not link.space.no_sharing_limit:
if 0 < settings.SHARING_LIMIT < link.request_count:
return False
link.request_count += 1
link.save()

View File

@@ -1,71 +0,0 @@
from django.core.cache import caches
from cookbook.helper.cache_helper import CacheHelper
from cookbook.helper.unit_conversion_helper import UnitConversionHelper
from cookbook.models import PropertyType, Unit, Food, Property, Recipe, Step
class FoodPropertyHelper:
space = None
def __init__(self, space):
"""
Helper to perform food property calculations
:param space: space to limit scope to
"""
self.space = space
def calculate_recipe_properties(self, recipe):
"""
Calculate all food properties for a given recipe.
:param recipe: recipe to calculate properties for
:return: dict of with property keys and total/food values for each property available
"""
ingredients = []
computed_properties = {}
for s in recipe.steps.all():
ingredients += s.ingredients.all()
property_types = caches['default'].get(CacheHelper(self.space).PROPERTY_TYPE_CACHE_KEY, None)
if not property_types:
property_types = PropertyType.objects.filter(space=self.space).all()
caches['default'].set(CacheHelper(self.space).PROPERTY_TYPE_CACHE_KEY, property_types, 60 * 60) # cache is cleared on property type save signal so long duration is fine
for fpt in property_types:
computed_properties[fpt.id] = {'id': fpt.id, 'name': fpt.name, 'icon': fpt.icon, 'description': fpt.description, 'unit': fpt.unit, 'food_values': {}, 'total_value': 0, 'missing_value': False}
uch = UnitConversionHelper(self.space)
for i in ingredients:
if i.food is not None:
conversions = uch.get_conversions(i)
for pt in property_types:
found_property = False
if i.food.properties_food_amount == 0 or i.food.properties_food_unit is None:
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}
computed_properties[pt.id]['missing_value'] = i.food.properties_food_unit is None
else:
for p in i.food.properties.all():
if p.property_type == pt:
for c in conversions:
if c.unit == i.food.properties_food_unit:
found_property = True
computed_properties[pt.id]['total_value'] += (c.amount / i.food.properties_food_amount) * p.property_amount
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:
computed_properties[pt.id]['missing_value'] = True
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}
return computed_properties
# small dict helper to add to existing key or create new, probably a better way of doing this
# TODO move to central helper ?
@staticmethod
def add_or_create(d, key, value, food):
if key in d:
d[key]['value'] += value
else:
d[key] = {'id': food.id, 'food': food.name, 'value': value}
return d

View File

@@ -3,9 +3,9 @@ from collections import Counter
from datetime import date, timedelta
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity
from django.core.cache import cache, caches
from django.db.models import (Avg, Case, Count, Exists, F, Func, Max, OuterRef, Q, Subquery, Value,
When)
from django.core.cache import cache
from django.core.cache import caches
from django.db.models import (Avg, Case, Count, Exists, F, Func, Max, OuterRef, Q, Subquery, Value, When, FilteredRelation)
from django.db.models.functions import Coalesce, Lower, Substr
from django.utils import timezone, translation
from django.utils.translation import gettext as _
@@ -20,8 +20,7 @@ from recipes import settings
# TODO create extensive tests to make sure ORs ANDs and various filters, sorting, etc work as expected
# TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering
class RecipeSearch():
_postgres = settings.DATABASES['default']['ENGINE'] in [
'django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']
_postgres = settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']
def __init__(self, request, **params):
self._request = request
@@ -46,8 +45,7 @@ class RecipeSearch():
cache.set(CACHE_KEY, self._search_prefs, timeout=10)
else:
self._search_prefs = SearchPreference()
self._string = self._params.get('query').strip(
) if self._params.get('query', None) else None
self._string = self._params.get('query').strip() if self._params.get('query', None) else None
self._rating = self._params.get('rating', None)
self._keywords = {
'or': self._params.get('keywords_or', None) or self._params.get('keywords', None),
@@ -76,8 +74,7 @@ class RecipeSearch():
self._random = str2bool(self._params.get('random', False))
self._new = str2bool(self._params.get('new', False))
self._num_recent = int(self._params.get('num_recent', 0))
self._include_children = str2bool(
self._params.get('include_children', None))
self._include_children = str2bool(self._params.get('include_children', None))
self._timescooked = self._params.get('timescooked', None)
self._cookedon = self._params.get('cookedon', None)
self._createdon = self._params.get('createdon', None)
@@ -98,24 +95,18 @@ class RecipeSearch():
self._search_type = self._search_prefs.search or 'plain'
if self._string:
if self._postgres:
self._unaccent_include = self._search_prefs.unaccent.values_list(
'field', flat=True)
self._unaccent_include = self._search_prefs.unaccent.values_list('field', flat=True)
else:
self._unaccent_include = []
self._icontains_include = [
x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.icontains.values_list('field', flat=True)]
self._istartswith_include = [
x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.istartswith.values_list('field', flat=True)]
self._icontains_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.icontains.values_list('field', flat=True)]
self._istartswith_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.istartswith.values_list('field', flat=True)]
self._trigram_include = None
self._fulltext_include = None
self._trigram = False
if self._postgres and self._string:
self._language = DICTIONARY.get(
translation.get_language(), 'simple')
self._trigram_include = [
x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.trigram.values_list('field', flat=True)]
self._fulltext_include = self._search_prefs.fulltext.values_list(
'field', flat=True) or None
self._language = DICTIONARY.get(translation.get_language(), 'simple')
self._trigram_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.trigram.values_list('field', flat=True)]
self._fulltext_include = self._search_prefs.fulltext.values_list('field', flat=True) or None
if self._search_type not in ['websearch', 'raw'] and self._trigram_include:
self._trigram = True
@@ -191,10 +182,8 @@ class RecipeSearch():
# otherwise sort by the remaining order_by attributes or favorite by default
else:
order += default_order
order[:] = [Lower('name').asc() if x ==
'name' else x for x in order]
order[:] = [Lower('name').desc() if x ==
'-name' else x for x in order]
order[:] = [Lower('name').asc() if x == 'name' else x for x in order]
order[:] = [Lower('name').desc() if x == '-name' else x for x in order]
self.orderby = order
def string_filters(self, string=None):
@@ -211,28 +200,21 @@ class RecipeSearch():
for f in self._filters:
query_filter |= f
# this creates duplicate records which can screw up other aggregates, see makenow for workaround
self._queryset = self._queryset.filter(query_filter).distinct()
self._queryset = self._queryset.filter(query_filter).distinct() # this creates duplicate records which can screw up other aggregates, see makenow for workaround
if self._fulltext_include:
if self._fuzzy_match is None:
self._queryset = self._queryset.annotate(
score=Coalesce(Max(self.search_rank), 0.0))
self._queryset = self._queryset.annotate(score=Coalesce(Max(self.search_rank), 0.0))
else:
self._queryset = self._queryset.annotate(
rank=Coalesce(Max(self.search_rank), 0.0))
self._queryset = self._queryset.annotate(rank=Coalesce(Max(self.search_rank), 0.0))
if self._fuzzy_match is not None:
simularity = self._fuzzy_match.filter(
pk=OuterRef('pk')).values('simularity')
simularity = self._fuzzy_match.filter(pk=OuterRef('pk')).values('simularity')
if not self._fulltext_include:
self._queryset = self._queryset.annotate(
score=Coalesce(Subquery(simularity), 0.0))
self._queryset = self._queryset.annotate(score=Coalesce(Subquery(simularity), 0.0))
else:
self._queryset = self._queryset.annotate(
simularity=Coalesce(Subquery(simularity), 0.0))
self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0))
if self._sort_includes('score') and self._fulltext_include and self._fuzzy_match is not None:
self._queryset = self._queryset.annotate(
score=F('rank') + F('simularity'))
self._queryset = self._queryset.annotate(score=F('rank') + F('simularity'))
else:
query_filter = Q()
for f in [x + '__unaccent__iexact' if x in self._unaccent_include else x + '__iexact' for x in SearchFields.objects.all().values_list('field', flat=True)]:
@@ -241,8 +223,7 @@ class RecipeSearch():
def _cooked_on_filter(self, cooked_date=None):
if self._sort_includes('lastcooked') or cooked_date:
lessthan = self._sort_includes(
'-lastcooked') or '-' in (cooked_date or [])[:1]
lessthan = self._sort_includes('-lastcooked') or '-' in (cooked_date or [])[:1]
if lessthan:
default = timezone.now() - timedelta(days=100000)
else:
@@ -252,41 +233,32 @@ class RecipeSearch():
if cooked_date is None:
return
cooked_date = date(*[int(x)
for x in cooked_date.split('-') if x != ''])
cooked_date = date(*[int(x) for x in cooked_date.split('-') if x != ''])
if lessthan:
self._queryset = self._queryset.filter(
lastcooked__date__lte=cooked_date).exclude(lastcooked=default)
self._queryset = self._queryset.filter(lastcooked__date__lte=cooked_date).exclude(lastcooked=default)
else:
self._queryset = self._queryset.filter(
lastcooked__date__gte=cooked_date).exclude(lastcooked=default)
self._queryset = self._queryset.filter(lastcooked__date__gte=cooked_date).exclude(lastcooked=default)
def _created_on_filter(self, created_date=None):
if created_date is None:
return
lessthan = '-' in created_date[:1]
created_date = date(*[int(x)
for x in created_date.split('-') if x != ''])
created_date = date(*[int(x) for x in created_date.split('-') if x != ''])
if lessthan:
self._queryset = self._queryset.filter(
created_at__date__lte=created_date)
self._queryset = self._queryset.filter(created_at__date__lte=created_date)
else:
self._queryset = self._queryset.filter(
created_at__date__gte=created_date)
self._queryset = self._queryset.filter(created_at__date__gte=created_date)
def _updated_on_filter(self, updated_date=None):
if updated_date is None:
return
lessthan = '-' in updated_date[:1]
updated_date = date(*[int(x)
for x in updated_date.split('-') if x != ''])
updated_date = date(*[int(x) for x in updated_date.split('-') if x != ''])
if lessthan:
self._queryset = self._queryset.filter(
updated_at__date__lte=updated_date)
self._queryset = self._queryset.filter(updated_at__date__lte=updated_date)
else:
self._queryset = self._queryset.filter(
updated_at__date__gte=updated_date)
self._queryset = self._queryset.filter(updated_at__date__gte=updated_date)
def _viewed_on_filter(self, viewed_date=None):
if self._sort_includes('lastviewed') or viewed_date:
@@ -296,15 +268,12 @@ class RecipeSearch():
if viewed_date is None:
return
lessthan = '-' in viewed_date[:1]
viewed_date = date(*[int(x)
for x in viewed_date.split('-') if x != ''])
viewed_date = date(*[int(x) for x in viewed_date.split('-') if x != ''])
if lessthan:
self._queryset = self._queryset.filter(
lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
self._queryset = self._queryset.filter(lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
else:
self._queryset = self._queryset.filter(
lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo)
self._queryset = self._queryset.filter(lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo)
def _new_recipes(self, new_days=7):
# TODO make new days a user-setting
@@ -324,32 +293,27 @@ class RecipeSearch():
num_recent_recipes = ViewLog.objects.filter(created_by=self._request.user, space=self._request.space).values(
'recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent]
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(
pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0)))
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0)))
def _favorite_recipes(self, times_cooked=None):
if self._sort_includes('favorite') or times_cooked:
less_than = '-' in (times_cooked or []
) and not self._sort_includes('-favorite')
less_than = '-' in (times_cooked or []) or not self._sort_includes('-favorite')
if less_than:
default = 1000
else:
default = 0
favorite_recipes = CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk')
).values('recipe').annotate(count=Count('pk', distinct=True)).values('count')
self._queryset = self._queryset.annotate(
favorite=Coalesce(Subquery(favorite_recipes), default))
self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default))
if times_cooked is None:
return
if times_cooked == '0':
self._queryset = self._queryset.filter(favorite=0)
elif less_than:
self._queryset = self._queryset.filter(favorite__lte=int(
times_cooked.replace('-', ''))).exclude(favorite=0)
self._queryset = self._queryset.filter(favorite__lte=int(times_cooked[1:])).exclude(favorite=0)
else:
self._queryset = self._queryset.filter(
favorite__gte=int(times_cooked))
self._queryset = self._queryset.filter(favorite__gte=int(times_cooked))
def keyword_filters(self, **kwargs):
if all([kwargs[x] is None for x in kwargs]):
@@ -382,8 +346,7 @@ class RecipeSearch():
else:
self._queryset = self._queryset.filter(f_and)
if 'not' in kw_filter:
self._queryset = self._queryset.exclude(
id__in=recipes.values('id'))
self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
def food_filters(self, **kwargs):
if all([kwargs[x] is None for x in kwargs]):
@@ -397,8 +360,7 @@ class RecipeSearch():
foods = Food.objects.filter(pk__in=kwargs[fd_filter])
if 'or' in fd_filter:
if self._include_children:
f_or = Q(
steps__ingredients__food__in=Food.include_descendants(foods))
f_or = Q(steps__ingredients__food__in=Food.include_descendants(foods))
else:
f_or = Q(steps__ingredients__food__in=foods)
@@ -410,8 +372,7 @@ class RecipeSearch():
recipes = Recipe.objects.all()
for food in foods:
if self._include_children:
f_and = Q(
steps__ingredients__food__in=food.get_descendants_and_self())
f_and = Q(steps__ingredients__food__in=food.get_descendants_and_self())
else:
f_and = Q(steps__ingredients__food=food)
if 'not' in fd_filter:
@@ -419,8 +380,7 @@ class RecipeSearch():
else:
self._queryset = self._queryset.filter(f_and)
if 'not' in fd_filter:
self._queryset = self._queryset.exclude(
id__in=recipes.values('id'))
self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
def unit_filters(self, units=None, operator=True):
if operator != True:
@@ -429,8 +389,7 @@ class RecipeSearch():
return
if not isinstance(units, list):
units = [units]
self._queryset = self._queryset.filter(
steps__ingredients__unit__in=units)
self._queryset = self._queryset.filter(steps__ingredients__unit__in=units)
def rating_filter(self, rating=None):
if rating or self._sort_includes('rating'):
@@ -440,16 +399,14 @@ class RecipeSearch():
else:
default = 0
# TODO make ratings a settings user-only vs all-users
self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(
cooklog__created_by=self._request.user, then='cooklog__rating'), default=default))))
self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then='cooklog__rating'), default=default))))
if rating is None:
return
if rating == '0':
self._queryset = self._queryset.filter(rating=0)
elif lessthan:
self._queryset = self._queryset.filter(
rating__lte=int(rating[1:])).exclude(rating=0)
self._queryset = self._queryset.filter(rating__lte=int(rating[1:])).exclude(rating=0)
else:
self._queryset = self._queryset.filter(rating__gte=int(rating))
@@ -477,14 +434,11 @@ class RecipeSearch():
recipes = Recipe.objects.all()
for book in kwargs[bk_filter]:
if 'not' in bk_filter:
recipes = recipes.filter(
recipebookentry__book__id=book)
recipes = recipes.filter(recipebookentry__book__id=book)
else:
self._queryset = self._queryset.filter(
recipebookentry__book__id=book)
self._queryset = self._queryset.filter(recipebookentry__book__id=book)
if 'not' in bk_filter:
self._queryset = self._queryset.exclude(
id__in=recipes.values('id'))
self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
def step_filters(self, steps=None, operator=True):
if operator != True:
@@ -492,7 +446,7 @@ class RecipeSearch():
if not steps:
return
if not isinstance(steps, list):
steps = [steps]
steps = [unistepsts]
self._queryset = self._queryset.filter(steps__id__in=steps)
def build_fulltext_filters(self, string=None):
@@ -503,25 +457,20 @@ class RecipeSearch():
rank = []
if 'name' in self._fulltext_include:
vectors.append('name_search_vector')
rank.append(SearchRank('name_search_vector',
self.search_query, cover_density=True))
rank.append(SearchRank('name_search_vector', self.search_query, cover_density=True))
if 'description' in self._fulltext_include:
vectors.append('desc_search_vector')
rank.append(SearchRank('desc_search_vector',
self.search_query, cover_density=True))
rank.append(SearchRank('desc_search_vector', self.search_query, cover_density=True))
if 'steps__instruction' in self._fulltext_include:
vectors.append('steps__search_vector')
rank.append(SearchRank('steps__search_vector',
self.search_query, cover_density=True))
rank.append(SearchRank('steps__search_vector', self.search_query, cover_density=True))
if 'keywords__name' in self._fulltext_include:
# explicitly settings unaccent on keywords and foods so that they behave the same as search_vector fields
vectors.append('keywords__name__unaccent')
rank.append(SearchRank('keywords__name__unaccent',
self.search_query, cover_density=True))
rank.append(SearchRank('keywords__name__unaccent', self.search_query, cover_density=True))
if 'steps__ingredients__food__name' in self._fulltext_include:
vectors.append('steps__ingredients__food__name__unaccent')
rank.append(SearchRank('steps__ingredients__food__name',
self.search_query, cover_density=True))
rank.append(SearchRank('steps__ingredients__food__name', self.search_query, cover_density=True))
for r in rank:
if self.search_rank is None:
@@ -529,8 +478,7 @@ class RecipeSearch():
else:
self.search_rank += r
# modifying queryset will annotation creates duplicate results
self._filters.append(Q(id__in=Recipe.objects.annotate(
vector=SearchVector(*vectors)).filter(Q(vector=self.search_query))))
self._filters.append(Q(id__in=Recipe.objects.annotate(vector=SearchVector(*vectors)).filter(Q(vector=self.search_query))))
def build_text_filters(self, string=None):
if not string:
@@ -562,30 +510,23 @@ class RecipeSearch():
def _makenow_filter(self, missing=None):
if missing is None or (type(missing) == bool and missing == False):
return
shopping_users = [
*self._request.user.get_shopping_share(), self._request.user]
shopping_users = [*self._request.user.get_shopping_share(), self._request.user]
onhand_filter = (
Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand
# or substitute food onhand
| Q(steps__ingredients__food__substitute__onhand_users__in=shopping_users)
| Q(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users))
| Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users))
Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand
| Q(steps__ingredients__food__substitute__onhand_users__in=shopping_users) # or substitute food onhand
| Q(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users))
| Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users))
)
makenow_recipes = Recipe.objects.annotate(
count_food=Count('steps__ingredients__food__pk', filter=Q(
steps__ingredients__food__isnull=False), distinct=True),
count_onhand=Count('steps__ingredients__food__pk',
filter=onhand_filter, distinct=True),
count_food=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__isnull=False), distinct=True),
count_onhand=Count('steps__ingredients__food__pk', filter=onhand_filter, distinct=True),
count_ignore_shopping=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__ignore_shopping=True,
steps__ingredients__food__recipe__isnull=True), distinct=True),
has_child_sub=Case(When(steps__ingredients__food__in=self.__children_substitute_filter(
shopping_users), then=Value(1)), default=Value(0)),
has_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(
shopping_users), then=Value(1)), default=Value(0))
has_child_sub=Case(When(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users), then=Value(1)), default=Value(0)),
has_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users), then=Value(1)), default=Value(0))
).annotate(missingfood=F('count_food') - F('count_onhand') - F('count_ignore_shopping')).filter(missingfood=missing)
self._queryset = self._queryset.distinct().filter(
id__in=makenow_recipes.values('id'))
self._queryset = self._queryset.distinct().filter(id__in=makenow_recipes.values('id'))
@staticmethod
def __children_substitute_filter(shopping_users=None):
@@ -606,8 +547,7 @@ class RecipeSearch():
@staticmethod
def __sibling_substitute_filter(shopping_users=None):
sibling_onhand_subquery = Food.objects.filter(
path__startswith=Substr(
OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)),
path__startswith=Substr(OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)),
depth=OuterRef('depth'),
onhand_users__in=shopping_users
)
@@ -646,8 +586,7 @@ class RecipeFacet():
self.Recent = self._cache.get('Recent', None)
if self._queryset is not None:
self._recipe_list = list(
self._queryset.values_list('id', flat=True))
self._recipe_list = list(self._queryset.values_list('id', flat=True))
self._search_params = {
'keyword_list': self._request.query_params.getlist('keywords', []),
'food_list': self._request.query_params.getlist('foods', []),
@@ -679,8 +618,7 @@ class RecipeFacet():
'Books': self.Books
}
caches['default'].set(self._SEARCH_CACHE_KEY,
self._cache, self._cache_timeout)
caches['default'].set(self._SEARCH_CACHE_KEY, self._cache, self._cache_timeout)
def get_facets(self, from_cache=False):
if from_cache:
@@ -717,16 +655,13 @@ class RecipeFacet():
def get_keywords(self):
if self.Keywords is None:
if self._search_params['search_keywords_or']:
keywords = Keyword.objects.filter(
space=self._request.space).distinct()
keywords = Keyword.objects.filter(space=self._request.space).distinct()
else:
keywords = Keyword.objects.filter(Q(recipe__in=self._recipe_list) | Q(
depth=1)).filter(space=self._request.space).distinct()
keywords = Keyword.objects.filter(Q(recipe__in=self._recipe_list) | Q(depth=1)).filter(space=self._request.space).distinct()
# set keywords to root objects only
keywords = self._keyword_queryset(keywords)
self.Keywords = [{**x, 'children': None}
if x['numchild'] > 0 else x for x in list(keywords)]
self.Keywords = [{**x, 'children': None} if x['numchild'] > 0 else x for x in list(keywords)]
self.set_cache('Keywords', self.Keywords)
return self.Keywords
@@ -734,28 +669,28 @@ class RecipeFacet():
if self.Foods is None:
# # if using an OR search, will annotate all keywords, otherwise, just those that appear in results
if self._search_params['search_foods_or']:
foods = Food.objects.filter(
space=self._request.space).distinct()
foods = Food.objects.filter(space=self._request.space).distinct()
else:
foods = Food.objects.filter(Q(ingredient__step__recipe__in=self._recipe_list) | Q(
depth=1)).filter(space=self._request.space).distinct()
foods = Food.objects.filter(Q(ingredient__step__recipe__in=self._recipe_list) | Q(depth=1)).filter(space=self._request.space).distinct()
# set keywords to root objects only
foods = self._food_queryset(foods)
self.Foods = [{**x, 'children': None}
if x['numchild'] > 0 else x for x in list(foods)]
self.Foods = [{**x, 'children': None} if x['numchild'] > 0 else x for x in list(foods)]
self.set_cache('Foods', self.Foods)
return self.Foods
def get_books(self):
if self.Books is None:
self.Books = []
return self.Books
def get_ratings(self):
if self.Ratings is None:
if not self._request.space.demo and self._request.space.show_facet_count:
if self._queryset is None:
self._queryset = Recipe.objects.filter(
id__in=self._recipe_list)
rating_qs = self._queryset.annotate(rating=Round(Avg(Case(When(
cooklog__created_by=self._request.user, then='cooklog__rating'), default=Value(0)))))
self._queryset = Recipe.objects.filter(id__in=self._recipe_list)
rating_qs = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then='cooklog__rating'), default=Value(0)))))
self.Ratings = dict(Counter(r.rating for r in rating_qs))
else:
self.Rating = {}
@@ -780,13 +715,10 @@ class RecipeFacet():
foods = self._food_queryset(food.get_children(), food)
deep_search = self.Foods
for node in nodes:
index = next((i for i, x in enumerate(
deep_search) if x["id"] == node.id), None)
index = next((i for i, x in enumerate(deep_search) if x["id"] == node.id), None)
deep_search = deep_search[index]['children']
index = next((i for i, x in enumerate(
deep_search) if x["id"] == food.id), None)
deep_search[index]['children'] = [
{**x, 'children': None} if x['numchild'] > 0 else x for x in list(foods)]
index = next((i for i, x in enumerate(deep_search) if x["id"] == food.id), None)
deep_search[index]['children'] = [{**x, 'children': None} if x['numchild'] > 0 else x for x in list(foods)]
self.set_cache('Foods', self.Foods)
return self.get_facets()
@@ -799,13 +731,10 @@ class RecipeFacet():
keywords = self._keyword_queryset(keyword.get_children(), keyword)
deep_search = self.Keywords
for node in nodes:
index = next((i for i, x in enumerate(
deep_search) if x["id"] == node.id), None)
index = next((i for i, x in enumerate(deep_search) if x["id"] == node.id), None)
deep_search = deep_search[index]['children']
index = next((i for i, x in enumerate(deep_search)
if x["id"] == keyword.id), None)
deep_search[index]['children'] = [
{**x, 'children': None} if x['numchild'] > 0 else x for x in list(keywords)]
index = next((i for i, x in enumerate(deep_search) if x["id"] == keyword.id), None)
deep_search[index]['children'] = [{**x, 'children': None} if x['numchild'] > 0 else x for x in list(keywords)]
self.set_cache('Keywords', self.Keywords)
return self.get_facets()

View File

@@ -1,9 +1,8 @@
# import random
import random
import re
import traceback
from html import unescape
from unicodedata import decomposition
from django.core.cache import caches
from django.utils.dateparse import parse_duration
from django.utils.translation import gettext as _
from isodate import parse_duration as iso_parse_duration
@@ -11,13 +10,9 @@ from isodate.isoerror import ISO8601Error
from pytube import YouTube
from recipe_scrapers._utils import get_host_name, get_minutes
# from cookbook.helper import recipe_url_import as helper
from cookbook.helper import recipe_url_import as helper
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.models import Automation, Keyword, PropertyType
# from unicodedata import decomposition
from cookbook.models import Keyword
# from recipe_scrapers._utils import get_minutes ## temporary until/unless upstream incorporates get_minutes() PR
@@ -35,9 +30,6 @@ def get_from_scraper(scrape, request):
except Exception:
recipe_json['name'] = ''
if isinstance(recipe_json['name'], list) and len(recipe_json['name']) > 0:
recipe_json['name'] = recipe_json['name'][0]
try:
description = scrape.description() or None
except Exception:
@@ -129,13 +121,7 @@ def get_from_scraper(scrape, request):
try:
keywords.append(source_url.replace('http://', '').replace('https://', '').split('/')[0])
except Exception:
recipe_json['source_url'] = ''
try:
if scrape.author():
keywords.append(scrape.author())
except Exception:
pass
pass
try:
recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), request.space)
@@ -153,92 +139,45 @@ def get_from_scraper(scrape, request):
if len(recipe_json['steps']) == 0:
recipe_json['steps'].append({'instruction': '', 'ingredients': [], })
parsed_description = parse_description(description)
# TODO notify user about limit if reached
# limits exist to limit the attack surface for dos style attacks
automations = Automation.objects.filter(type=Automation.DESCRIPTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').all().order_by('order')[:512]
for a in automations:
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
parsed_description = re.sub(a.param_2, a.param_3, parsed_description, count=1)
if len(parsed_description) > 256: # split at 256 as long descriptions don't look good on recipe cards
recipe_json['steps'][0]['instruction'] = f'*{parsed_description}* \n\n' + recipe_json['steps'][0]['instruction']
if len(parse_description(description)) > 256: # split at 256 as long descriptions dont look good on recipe cards
recipe_json['steps'][0]['instruction'] = f'*{parse_description(description)}* \n\n' + recipe_json['steps'][0]['instruction']
else:
recipe_json['description'] = parsed_description[:512]
recipe_json['description'] = parse_description(description)[:512]
try:
for x in scrape.ingredients():
if x.strip() != '':
try:
amount, unit, ingredient, note = ingredient_parser.parse(x)
ingredient = {
'amount': amount,
'food': {
'name': ingredient,
},
try:
amount, unit, ingredient, note = ingredient_parser.parse(x)
ingredient = {
'amount': amount,
'food': {
'name': ingredient,
},
'unit': None,
'note': note,
'original_text': x
}
if unit:
ingredient['unit'] = {'name': unit, }
recipe_json['steps'][0]['ingredients'].append(ingredient)
except Exception:
recipe_json['steps'][0]['ingredients'].append(
{
'amount': 0,
'unit': None,
'note': note,
'food': {
'name': x,
},
'note': '',
'original_text': x
}
if unit:
ingredient['unit'] = {'name': unit, }
recipe_json['steps'][0]['ingredients'].append(ingredient)
except Exception:
recipe_json['steps'][0]['ingredients'].append(
{
'amount': 0,
'unit': None,
'food': {
'name': x,
},
'note': '',
'original_text': x
}
)
)
except Exception:
pass
try:
recipe_json['properties'] = get_recipe_properties(request.space, scrape.schema.nutrients())
print(recipe_json['properties'])
except Exception:
traceback.print_exc()
pass
if 'source_url' in recipe_json and recipe_json['source_url']:
automations = Automation.objects.filter(type=Automation.INSTRUCTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').order_by('order').all()[:512]
for a in automations:
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
for s in recipe_json['steps']:
s['instruction'] = re.sub(a.param_2, a.param_3, s['instruction'])
return recipe_json
def get_recipe_properties(space, property_data):
# {'servingSize': '1', 'calories': '302 kcal', 'proteinContent': '7,66g', 'fatContent': '11,56g', 'carbohydrateContent': '41,33g'}
properties = {
"property-calories": "calories",
"property-carbohydrates": "carbohydrateContent",
"property-proteins": "proteinContent",
"property-fats": "fatContent",
}
recipe_properties = []
for pt in PropertyType.objects.filter(space=space, open_data_slug__in=list(properties.keys())).all():
for p in list(properties.keys()):
if pt.open_data_slug == p:
if properties[p] in property_data:
recipe_properties.append({
'property_type': {
'id': pt.id,
'name': pt.name,
},
'property_amount': parse_servings(property_data[properties[p]]) / float(property_data['servingSize']),
})
return recipe_properties
def get_from_youtube_scraper(url, request):
"""A YouTube Information Scraper."""
kw, created = Keyword.objects.get_or_create(name='YouTube', space=request.space)
@@ -285,27 +224,10 @@ def parse_description(description):
def clean_instruction_string(instruction):
# handle HTML tags that can be converted to markup
normalized_string = instruction \
.replace("<nobr>", "**") \
.replace("</nobr>", "**") \
.replace("<strong>", "**") \
.replace("</strong>", "**")
normalized_string = normalize_string(normalized_string)
normalized_string = normalize_string(instruction)
normalized_string = normalized_string.replace('\n', ' \n')
normalized_string = normalized_string.replace(' \n \n', '\n\n')
# handle unsupported, special UTF8 character in Thermomix-specific instructions,
# that happen in nearly every recipe on Cookidoo, Zaubertopf Club, Rezeptwelt
# and in Thermomix-specific recipes on many other sites
return normalized_string \
.replace("", _('reverse rotation')) \
.replace("", _('careful rotation')) \
.replace("", _('knead')) \
.replace("Andicken ", _('thicken')) \
.replace("Erwärmen ", _('warm up')) \
.replace("Fermentieren ", _('ferment')) \
.replace("Sous-vide ", _("sous-vide"))
return normalized_string
def parse_instructions(instructions):
@@ -377,11 +299,6 @@ def parse_servings_text(servings):
servings = re.sub("\d+", '', servings).strip()
except Exception:
servings = ''
if type(servings) == list:
try:
servings = parse_servings_text(servings[1])
except Exception:
pass
return str(servings)[:32]
@@ -405,28 +322,10 @@ def parse_time(recipe_time):
def parse_keywords(keyword_json, space):
keywords = []
keyword_aliases = {}
# retrieve keyword automation cache if it exists, otherwise build from database
KEYWORD_CACHE_KEY = f'automation_keyword_alias_{space.pk}'
if c := caches['default'].get(KEYWORD_CACHE_KEY, None):
keyword_aliases = c
caches['default'].touch(KEYWORD_CACHE_KEY, 30)
else:
for a in Automation.objects.filter(space=space, disabled=False, type=Automation.KEYWORD_ALIAS).only('param_1', 'param_2').order_by('order').all():
keyword_aliases[a.param_1] = a.param_2
caches['default'].set(KEYWORD_CACHE_KEY, keyword_aliases, 30)
# keywords as list
for kw in keyword_json:
kw = normalize_string(kw)
# if alias exists use that instead
if len(kw) != 0:
if keyword_aliases:
try:
kw = keyword_aliases[kw]
except KeyError:
pass
if k := Keyword.objects.filter(name=kw, space=space).first():
keywords.append({'label': str(k), 'name': k.name, 'id': k.id})
else:
@@ -497,18 +396,3 @@ def get_images_from_soup(soup, url):
if 'http' in u:
images.append(u)
return images
def clean_dict(input_dict, key):
if type(input_dict) == dict:
for x in list(input_dict):
if x == key:
del input_dict[x]
elif type(input_dict[x]) == dict:
input_dict[x] = clean_dict(input_dict[x], key)
elif type(input_dict[x]) == list:
temp_list = []
for e in input_dict[x]:
temp_list.append(clean_dict(e, key))
return input_dict

View File

@@ -47,8 +47,6 @@ class RecipeShoppingEditor():
self.mealplan = self._kwargs.get('mealplan', None)
if type(self.mealplan) in [int, float]:
self.mealplan = MealPlan.objects.filter(id=self.mealplan, space=self.space)
if type(self.mealplan) == dict:
self.mealplan = MealPlan.objects.filter(id=self.mealplan['id'], space=self.space).first()
self.id = self._kwargs.get('id', None)
self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
@@ -109,10 +107,7 @@ class RecipeShoppingEditor():
self.servings = float(servings)
if mealplan := kwargs.get('mealplan', None):
if type(mealplan) == dict:
self.mealplan = MealPlan.objects.filter(id=mealplan['id'], space=self.space).first()
else:
self.mealplan = mealplan
self.mealplan = mealplan
self.recipe = mealplan.recipe
elif recipe := kwargs.get('recipe', None):
self.recipe = recipe
@@ -315,4 +310,4 @@ class RecipeShoppingEditor():
# )
# # return all shopping list items
# return list_recipe
# return list_recipe

View File

@@ -22,25 +22,10 @@ class IngredientObject(object):
else:
self.amount = f"<scalable-number v-bind:number='{bleach.clean(str(ingredient.amount))}' v-bind:factor='ingredient_factor'></scalable-number>"
if ingredient.unit:
if ingredient.unit.plural_name in (None, ""):
self.unit = bleach.clean(str(ingredient.unit))
else:
if ingredient.always_use_plural_unit or ingredient.amount > 1 and not ingredient.no_amount:
self.unit = bleach.clean(ingredient.unit.plural_name)
else:
self.unit = bleach.clean(str(ingredient.unit))
self.unit = bleach.clean(str(ingredient.unit))
else:
self.unit = ""
if ingredient.food:
if ingredient.food.plural_name in (None, ""):
self.food = bleach.clean(str(ingredient.food))
else:
if ingredient.always_use_plural_food or ingredient.amount > 1 and not ingredient.no_amount:
self.food = bleach.clean(str(ingredient.food.plural_name))
else:
self.food = bleach.clean(str(ingredient.food))
else:
self.food = ""
self.food = bleach.clean(str(ingredient.food))
self.note = bleach.clean(str(ingredient.note))
def __str__(self):

View File

@@ -1,141 +0,0 @@
from django.core.cache import caches
from decimal import Decimal
from cookbook.helper.cache_helper import CacheHelper
from cookbook.models import Ingredient, Unit
CONVERSION_TABLE = {
'weight': {
'g': 1000,
'kg': 1,
'ounce': 35.274,
'pound': 2.20462
},
'volume': {
'ml': 1000,
'l': 1,
'fluid_ounce': 33.814,
'pint': 2.11338,
'quart': 1.05669,
'gallon': 0.264172,
'tbsp': 67.628,
'tsp': 202.884,
'imperial_fluid_ounce': 35.1951,
'imperial_pint': 1.75975,
'imperial_quart': 0.879877,
'imperial_gallon': 0.219969,
'imperial_tbsp': 56.3121,
'imperial_tsp': 168.936,
},
}
BASE_UNITS_WEIGHT = list(CONVERSION_TABLE['weight'].keys())
BASE_UNITS_VOLUME = list(CONVERSION_TABLE['volume'].keys())
class ConversionException(Exception):
pass
class UnitConversionHelper:
space = None
def __init__(self, space):
"""
Initializes unit conversion helper
:param space: space to perform conversions on
"""
self.space = space
@staticmethod
def convert_from_to(from_unit, to_unit, amount):
"""
Convert from one base unit to another. Throws ConversionException if trying to convert between different systems (weight/volume) or if units are not supported.
:param from_unit: str unit to convert from
:param to_unit: str unit to convert to
:param amount: amount to convert
:return: Decimal converted amount
"""
system = None
if from_unit in BASE_UNITS_WEIGHT and to_unit in BASE_UNITS_WEIGHT:
system = 'weight'
if from_unit in BASE_UNITS_VOLUME and to_unit in BASE_UNITS_VOLUME:
system = 'volume'
if not system:
raise ConversionException('Trying to convert units not existing or not in one unit system (weight/volume)')
return Decimal(amount / Decimal(CONVERSION_TABLE[system][from_unit] / CONVERSION_TABLE[system][to_unit]))
def base_conversions(self, ingredient_list):
"""
Calculates all possible base unit conversions for each ingredient give.
Converts to all common base units IF they exist in the unit database of the space.
For useful results all ingredients passed should be of the same food, otherwise filtering afterwards might be required.
:param ingredient_list: list of ingredients to convert
:return: ingredient list with appended conversions
"""
base_conversion_ingredient_list = ingredient_list.copy()
for i in ingredient_list:
try:
conversion_unit = i.unit.name
if i.unit.base_unit:
conversion_unit = i.unit.base_unit
# TODO allow setting which units to convert to? possibly only once conversions become visible
units = caches['default'].get(CacheHelper(self.space).BASE_UNITS_CACHE_KEY, None)
if not units:
units = Unit.objects.filter(space=self.space, base_unit__in=(BASE_UNITS_VOLUME + BASE_UNITS_WEIGHT)).all()
caches['default'].set(CacheHelper(self.space).BASE_UNITS_CACHE_KEY, units, 60 * 60) # cache is cleared on unit save signal so long duration is fine
for u in units:
try:
ingredient = Ingredient(amount=self.convert_from_to(conversion_unit, u.base_unit, i.amount), unit=u, food=ingredient_list[0].food, )
if not any((x.unit.name == ingredient.unit.name or x.unit.base_unit == ingredient.unit.name) for x in base_conversion_ingredient_list):
base_conversion_ingredient_list.append(ingredient)
except ConversionException:
pass
except Exception:
pass
return base_conversion_ingredient_list
def get_conversions(self, ingredient):
"""
Converts an ingredient to all possible conversions based on the custom unit conversion database.
After that passes conversion to UnitConversionHelper.base_conversions() to get all base conversions possible.
:param ingredient: Ingredient object
:return: list of ingredients with all possible custom and base conversions
"""
conversions = [ingredient]
if ingredient.unit:
for c in ingredient.unit.unit_conversion_base_relation.all():
if c.space == self.space:
r = self._uc_convert(c, ingredient.amount, ingredient.unit, ingredient.food)
if r and r not in conversions:
conversions.append(r)
for c in ingredient.unit.unit_conversion_converted_relation.all():
if c.space == self.space:
r = self._uc_convert(c, ingredient.amount, ingredient.unit, ingredient.food)
if r and r not in conversions:
conversions.append(r)
conversions = self.base_conversions(conversions)
return conversions
def _uc_convert(self, uc, amount, unit, food):
"""
Helper to calculate values for custom unit conversions.
Converts given base values using the passed UnitConversion object into a converted Ingredient
:param uc: UnitConversion object
:param amount: base amount
:param unit: base unit
:param food: base food
:return: converted ingredient object from base amount/unit/food
"""
if uc.food is None or uc.food == food:
if unit == uc.base_unit:
return Ingredient(amount=amount * (uc.converted_amount / uc.base_amount), unit=uc.converted_unit, food=food, space=self.space)
else:
return Ingredient(amount=amount * (uc.base_amount / uc.converted_amount), unit=uc.base_unit, food=food, space=self.space)

View File

@@ -1,12 +1,16 @@
import traceback
import time
import datetime
import json
import traceback
import uuid
from io import BytesIO
from io import BytesIO, StringIO
from zipfile import BadZipFile, ZipFile
from bs4 import Tag
import lxml
from django.core.cache import cache
import datetime
from bs4 import Tag
from django.core.exceptions import ObjectDoesNotExist
from django.core.files import File
from django.db import IntegrityError
@@ -16,7 +20,8 @@ from django.utils.translation import gettext as _
from django_scopes import scope
from lxml import etree
from cookbook.helper.image_processing import handle_image
from cookbook.forms import ImportExportBase
from cookbook.helper.image_processing import get_filetype, handle_image
from cookbook.models import Keyword, Recipe
from recipes.settings import DEBUG
from recipes.settings import EXPORT_FILE_CACHE_DURATION
@@ -177,7 +182,7 @@ class Integration:
traceback.print_exc()
self.handle_exception(e, log=il, message=f'-------------------- \nERROR \n{e}\n--------------------\n')
import_zip.close()
elif '.json' in f['name'] or '.xml' in f['name'] or '.txt' in f['name'] or '.mmf' in f['name'] or '.rk' in f['name'] or '.melarecipe' in f['name']:
elif '.json' in f['name'] or '.txt' in f['name'] or '.mmf' in f['name'] or '.rk' in f['name'] or '.melarecipe' in f['name']:
data_list = self.split_recipe_file(f['file'])
il.total_recipes += len(data_list)
for d in data_list:

View File

@@ -5,7 +5,6 @@ from zipfile import ZipFile
from cookbook.helper.image_processing import get_filetype
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Recipe, Step
@@ -24,60 +23,41 @@ class Mealie(Integration):
name=recipe_json['name'].strip(), description=description,
created_by=self.request.user, internal=True, space=self.request.space)
# TODO parse times (given in PT2H3M )
# @vabene check recipe_url_import.iso_duration_to_minutes I think it does what you are looking for
ingredients_added = False
for s in recipe_json['recipe_instructions']:
step = Step.objects.create(instruction=s['text'], space=self.request.space, )
recipe.steps.add(step)
step = recipe.steps.first()
if not step: # if there is no step in the exported data
step = Step.objects.create(instruction='', space=self.request.space, )
recipe.steps.add(step)
if len(recipe_json['description'].strip()) > 500:
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
ingredient_parser = IngredientParser(self.request, True)
for ingredient in recipe_json['recipe_ingredient']:
try:
if ingredient['food']:
f = ingredient_parser.get_food(ingredient['food'])
u = ingredient_parser.get_unit(ingredient['unit'])
amount = ingredient['quantity']
note = ingredient['note']
original_text = None
else:
amount, unit, food, note = ingredient_parser.parse(ingredient['note'])
f = ingredient_parser.get_food(food)
u = ingredient_parser.get_unit(unit)
original_text = ingredient['note']
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, original_text=original_text, space=self.request.space,
))
except Exception:
pass
if 'notes' in recipe_json and len(recipe_json['notes']) > 0:
notes_text = "#### Notes \n\n"
for n in recipe_json['notes']:
notes_text += f'{n["text"]} \n'
step = Step.objects.create(
instruction=notes_text, space=self.request.space,
instruction=s['text'], space=self.request.space,
)
if not ingredients_added:
ingredients_added = True
if len(recipe_json['description'].strip()) > 500:
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
ingredient_parser = IngredientParser(self.request, True)
for ingredient in recipe_json['recipe_ingredient']:
try:
if ingredient['food']:
f = ingredient_parser.get_food(ingredient['food'])
u = ingredient_parser.get_unit(ingredient['unit'])
amount = ingredient['quantity']
note = ingredient['note']
original_text = None
else:
amount, unit, food, note = ingredient_parser.parse(ingredient['note'])
f = ingredient_parser.get_food(food)
u = ingredient_parser.get_unit(unit)
original_text = ingredient['note']
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, original_text=original_text, space=self.request.space,
))
except Exception:
pass
recipe.steps.add(step)
if 'recipe_yield' in recipe_json:
recipe.servings = parse_servings(recipe_json['recipe_yield'])
recipe.servings_text = parse_servings_text(recipe_json['recipe_yield'])
if 'total_time' in recipe_json and recipe_json['total_time'] is not None:
recipe.working_time = parse_time(recipe_json['total_time'])
if 'org_url' in recipe_json:
recipe.source_url = recipe_json['org_url']
recipe.save()
for f in self.files:
if '.zip' in f['name']:
import_zip = ZipFile(f['file'])

View File

@@ -1,8 +1,7 @@
import json
import re
from io import BytesIO, StringIO
from io import BytesIO
from zipfile import ZipFile
from PIL import Image
from cookbook.helper.image_processing import get_filetype
from cookbook.helper.ingredient_parser import IngredientParser
@@ -51,15 +50,9 @@ class NextcloudCookbook(Integration):
ingredients_added = False
for s in recipe_json['recipeInstructions']:
instruction_text = ''
if 'text' in s:
step = Step.objects.create(
instruction=s['text'], name=s['name'], space=self.request.space,
)
else:
step = Step.objects.create(
instruction=s, space=self.request.space,
)
step = Step.objects.create(
instruction=s, space=self.request.space,
)
if not ingredients_added:
if len(recipe_json['description'].strip()) > 500:
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
@@ -103,90 +96,5 @@ class NextcloudCookbook(Integration):
return recipe
def formatTime(self, min):
h = min // 60
m = min % 60
return f'PT{h}H{m}M0S'
def get_file_from_recipe(self, recipe):
export = {}
export['name'] = recipe.name
export['description'] = recipe.description
export['url'] = recipe.source_url
export['prepTime'] = self.formatTime(recipe.working_time)
export['cookTime'] = self.formatTime(recipe.waiting_time)
export['totalTime'] = self.formatTime(recipe.working_time + recipe.waiting_time)
export['recipeYield'] = recipe.servings
export['image'] = f'/Recipes/{recipe.name}/full.jpg'
export['imageUrl'] = f'/Recipes/{recipe.name}/full.jpg'
recipeKeyword = []
for k in recipe.keywords.all():
recipeKeyword.append(k.name)
export['keywords'] = recipeKeyword
recipeInstructions = []
recipeIngredient = []
for s in recipe.steps.all():
recipeInstructions.append(s.instruction)
for i in s.ingredients.all():
recipeIngredient.append(f'{float(i.amount)} {i.unit} {i.food}')
export['recipeIngredient'] = recipeIngredient
export['recipeInstructions'] = recipeInstructions
return "recipe.json", json.dumps(export)
def get_files_from_recipes(self, recipes, el, cookie):
export_zip_stream = BytesIO()
export_zip_obj = ZipFile(export_zip_stream, 'w')
for recipe in recipes:
if recipe.internal and recipe.space == self.request.space:
recipe_stream = StringIO()
filename, data = self.get_file_from_recipe(recipe)
recipe_stream.write(data)
export_zip_obj.writestr(f'{recipe.name}/{filename}', recipe_stream.getvalue())
recipe_stream.close()
try:
imageByte = recipe.image.file.read()
export_zip_obj.writestr(f'{recipe.name}/full.jpg', self.getJPEG(imageByte))
export_zip_obj.writestr(f'{recipe.name}/thumb.jpg', self.getThumb(171, imageByte))
export_zip_obj.writestr(f'{recipe.name}/thumb16.jpg', self.getThumb(16, imageByte))
except ValueError:
pass
el.exported_recipes += 1
el.msg += self.get_recipe_processed_msg(recipe)
el.save()
export_zip_obj.close()
return [[self.get_export_file_name(), export_zip_stream.getvalue()]]
def getJPEG(self, imageByte):
image = Image.open(BytesIO(imageByte))
image = image.convert('RGB')
bytes = BytesIO()
image.save(bytes, "JPEG")
return bytes.getvalue()
def getThumb(self, size, imageByte):
image = Image.open(BytesIO(imageByte))
w, h = image.size
m = min(w, h)
image = image.crop(((w - m) // 2, (h - m) // 2, (w + m) // 2, (h + m) // 2))
image = image.resize([size, size], Image.Resampling.LANCZOS)
image = image.convert('RGB')
bytes = BytesIO()
image.save(bytes, "JPEG")
return bytes.getvalue()
raise NotImplementedError('Method not implemented in storage integration')

View File

@@ -2,54 +2,24 @@ import json
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Recipe, Step, Keyword, Comment, CookLog
from django.utils.translation import gettext as _
from cookbook.models import Ingredient, Recipe, Step
class OpenEats(Integration):
def get_recipe_from_file(self, file):
description = file['info']
description_max_length = Recipe._meta.get_field('description').max_length
if len(description) > description_max_length:
description = description[0:description_max_length]
recipe = Recipe.objects.create(name=file['name'].strip(), description=description, created_by=self.request.user, internal=True,
recipe = Recipe.objects.create(name=file['name'].strip(), created_by=self.request.user, internal=True,
servings=file['servings'], space=self.request.space, waiting_time=file['cook_time'], working_time=file['prep_time'])
instructions = ''
if file["info"] != '':
instructions += file["info"]
if file["directions"] != '':
instructions += file["directions"]
if file["source"] != '':
instructions += '\n' + _('Recipe source:') + f'[{file["source"]}]({file["source"]})'
cuisine_keyword, created = Keyword.objects.get_or_create(name="Cuisine", space=self.request.space)
if file["cuisine"] != '':
keyword, created = Keyword.objects.get_or_create(name=file["cuisine"].strip(), space=self.request.space)
if created:
keyword.move(cuisine_keyword, pos="last-child")
recipe.keywords.add(keyword)
course_keyword, created = Keyword.objects.get_or_create(name="Course", space=self.request.space)
if file["course"] != '':
keyword, created = Keyword.objects.get_or_create(name=file["course"].strip(), space=self.request.space)
if created:
keyword.move(course_keyword, pos="last-child")
recipe.keywords.add(keyword)
for tag in file["tags"]:
keyword, created = Keyword.objects.get_or_create(name=tag.strip(), space=self.request.space)
recipe.keywords.add(keyword)
for comment in file['comments']:
Comment.objects.create(recipe=recipe, text=comment['text'], created_by=self.request.user)
CookLog.objects.create(recipe=recipe, rating=comment['rating'], created_by=self.request.user, space=self.request.space)
if file["photo"] != '':
recipe.image = f'recipes/openeats-import/{file["photo"]}'
recipe.save()
instructions += file["source"]
step = Step.objects.create(instruction=instructions, space=self.request.space,)
@@ -68,9 +38,6 @@ class OpenEats(Integration):
recipe_json = json.loads(file.read())
recipe_dict = {}
ingredient_group_dict = {}
cuisine_group_dict = {}
course_group_dict = {}
tag_group_dict = {}
for o in recipe_json:
if o['model'] == 'recipe.recipe':
@@ -83,27 +50,11 @@ class OpenEats(Integration):
'cook_time': o['fields']['cook_time'],
'servings': o['fields']['servings'],
'ingredients': [],
'photo': o['fields']['photo'],
'cuisine': o['fields']['cuisine'],
'course': o['fields']['course'],
'tags': o['fields']['tags'],
'comments': [],
}
if o['model'] == 'ingredient.ingredientgroup':
ingredient_group_dict[o['pk']] = o['fields']['recipe']
if o['model'] == 'recipe_groups.cuisine':
cuisine_group_dict[o['pk']] = o['fields']['title']
if o['model'] == 'recipe_groups.course':
course_group_dict[o['pk']] = o['fields']['title']
if o['model'] == 'recipe_groups.tag':
tag_group_dict[o['pk']] = o['fields']['title']
for o in recipe_json:
if o['model'] == 'rating.rating':
recipe_dict[o['fields']['recipe']]["comments"].append({
"text": o['fields']['comment'],
"rating": o['fields']['rating']
})
if o['model'] == 'ingredient.ingredient':
ingredient = {
'food': o['fields']['title'],
@@ -112,15 +63,6 @@ class OpenEats(Integration):
}
recipe_dict[ingredient_group_dict[o['fields']['ingredient_group']]]['ingredients'].append(ingredient)
for k, r in recipe_dict.items():
if r["cuisine"] in cuisine_group_dict:
r["cuisine"] = cuisine_group_dict[r["cuisine"]]
if r["course"] in course_group_dict:
r["course"] = course_group_dict[r["course"]]
for index in range(len(r["tags"])):
if r["tags"][index] in tag_group_dict:
r["tags"][index] = tag_group_dict[r["tags"][index]]
return list(recipe_dict.values())
def get_file_from_recipe(self, recipe):

View File

@@ -5,9 +5,6 @@ import re
from gettext import gettext as _
from io import BytesIO
import requests
import validators
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text
from cookbook.integration.integration import Integration
@@ -84,14 +81,7 @@ class Paprika(Integration):
recipe.steps.add(step)
try:
if recipe_json.get("image_url", None):
url = recipe_json.get("image_url", None)
if validators.url(url, public=True):
response = requests.get(url)
self.import_recipe_image(recipe, BytesIO(response.content))
except:
if recipe_json.get("photo_data", None):
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])), filetype='.jpeg')
if recipe_json.get("photo_data", None):
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])), filetype='.jpeg')
return recipe

View File

@@ -1,7 +1,6 @@
from io import BytesIO
import requests
import validators
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
@@ -68,9 +67,8 @@ class Plantoeat(Integration):
if image_url:
try:
if validators.url(image_url, public=True):
response = requests.get(image_url)
self.import_recipe_image(recipe, BytesIO(response.content))
response = requests.get(image_url)
self.import_recipe_image(recipe, BytesIO(response.content))
except Exception as e:
print('failed to import image ', str(e))

View File

@@ -61,7 +61,7 @@ class RecetteTek(Integration):
ingredient_parser = IngredientParser(self.request, True)
for ingredient in file['ingredients'].split('\n'):
if len(ingredient.strip()) > 0:
amount, unit, food, note = ingredient_parser.parse(ingredient.strip())
amount, unit, food, note = ingredient_parser.parse(food)
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(

View File

@@ -58,13 +58,6 @@ class RecipeKeeper(Integration):
if s.text == "":
continue
step.instruction += s.text + ' \n'
step.save()
for s in file.find("div", {"itemprop": "recipeNotes"}).find_all("p"):
if s.text == "":
continue
step.instruction += s.text + ' \n'
step.save()
if file.find("span", {"itemprop": "recipeSource"}).text != '':
step.instruction += "\n\n" + _("Imported from") + ": " + file.find("span", {"itemprop": "recipeSource"}).text

View File

@@ -5,7 +5,6 @@ import requests
import validators
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Recipe, Step
@@ -19,21 +18,19 @@ class RecipeSage(Integration):
created_by=self.request.user, internal=True,
space=self.request.space)
if file['recipeYield'] != '':
recipe.servings = parse_servings(file['recipeYield'])
recipe.servings_text = parse_servings_text(file['recipeYield'])
try:
if 'totalTime' in file and file['totalTime'] != '':
recipe.working_time = parse_time(file['totalTime'])
if file['recipeYield'] != '':
recipe.servings = int(file['recipeYield'])
if 'timePrep' in file and file['prepTime'] != '':
recipe.working_time = parse_time(file['timePrep'])
recipe.waiting_time = parse_time(file['totalTime']) - parse_time(file['timePrep'])
if file['totalTime'] != '':
recipe.waiting_time = int(file['totalTime']) - int(file['timePrep'])
if file['prepTime'] != '':
recipe.working_time = int(file['timePrep'])
recipe.save()
except Exception as e:
print('failed to parse time ', str(e))
recipe.save()
print('failed to parse yield or time ', str(e))
ingredient_parser = IngredientParser(self.request, True)
ingredients_added = False

View File

@@ -1,77 +0,0 @@
import base64
from io import BytesIO
from xml import etree
from lxml import etree
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_time, parse_servings, parse_servings_text
from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Recipe, Step, Keyword
class Rezeptsuitede(Integration):
def split_recipe_file(self, file):
return etree.parse(file).getroot().getchildren()
def get_recipe_from_file(self, file):
recipe_xml = file
recipe = Recipe.objects.create(
name=recipe_xml.find('head').attrib['title'].strip(),
created_by=self.request.user, internal=True, space=self.request.space)
try:
if recipe_xml.find('head').attrib['servingtype']:
recipe.servings = parse_servings(recipe_xml.find('head').attrib['servingtype'].strip())
recipe.servings_text = parse_servings_text(recipe_xml.find('head').attrib['servingtype'].strip())
except KeyError:
pass
if recipe_xml.find('remark') is not None: # description is a list of <li>'s with text
if recipe_xml.find('remark').find('line') is not None:
recipe.description = recipe_xml.find('remark').find('line').text[:512]
for prep in recipe_xml.findall('preparation'):
try:
if prep.find('step').text:
step = Step.objects.create(
instruction=prep.find('step').text.strip(), space=self.request.space,
)
recipe.steps.add(step)
except Exception:
pass
ingredient_parser = IngredientParser(self.request, True)
if recipe_xml.find('part').find('ingredient') is not None:
ingredient_step = recipe.steps.first()
if ingredient_step is None:
ingredient_step = Step.objects.create(space=self.request.space, instruction='')
for ingredient in recipe_xml.find('part').findall('ingredient'):
f = ingredient_parser.get_food(ingredient.attrib['item'])
u = ingredient_parser.get_unit(ingredient.attrib['unit'])
amount = 0
if ingredient.attrib['qty'].strip() != '':
amount, unit, note = ingredient_parser.parse_amount(ingredient.attrib['qty'])
ingredient_step.ingredients.add(Ingredient.objects.create(food=f, unit=u, amount=amount, space=self.request.space, ))
try:
k, created = Keyword.objects.get_or_create(name=recipe_xml.find('head').find('cat').text.strip(), space=self.request.space)
recipe.keywords.add(k)
except Exception as e:
pass
recipe.save()
try:
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_xml.find('head').find('picbin').text)), filetype='.jpeg')
except:
pass
return recipe
def get_file_from_recipe(self, recipe):
raise NotImplementedError('Method not implemented in storage integration')

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-29 18:42+0200\n"
"PO-Revision-Date: 2023-04-12 11:55+0000\n"
"Last-Translator: noxonad <noxonad@proton.me>\n"
"PO-Revision-Date: 2022-05-10 15:32+0000\n"
"Last-Translator: zeon <zeonbg@gmail.com>\n"
"Language-Team: Bulgarian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/bg/>\n"
"Language: bg\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.15\n"
"X-Generator: Weblate 4.10.1\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\forms\ingredients.html:34
#: .\cookbook\templates\space.html:49 .\cookbook\templates\stats.html:28
@@ -1433,7 +1433,7 @@ msgstr ""
#: .\cookbook\templates\index.html:29
msgid "Search recipe ..."
msgstr "Търсете рецепта ..."
msgstr "Търсете рецепта..."
#: .\cookbook\templates\index.html:44
msgid "New Recipe"
@@ -1818,7 +1818,7 @@ msgid ""
msgstr ""
" \n"
" Пълнотекстови търсения се опитват да нормализират предоставените "
"думи, за да съответстват на често срещани варианти. Например: 'вили, "
"думи, за да съответстват на често срещани варианти. Например: 'вили, "
"'вилица', 'вилици' всички ще се нормализират до 'вилиц'.\n"
" Има няколко налични метода, описани по-долу, които ще "
"контролират как поведението при търсене трябва да реагира, когато се търсят "

File diff suppressed because it is too large Load Diff

View File

@@ -6,22 +6,20 @@
# Translators:
# Pavel Solař <pavelsolar86@gmail.com>, 2021
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-02-09 18:01+0100\n"
"PO-Revision-Date: 2023-03-25 11:32+0000\n"
"Last-Translator: Matěj Kubla <matykubla@gmail.com>\n"
"Language-Team: Czech <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/cs/>\n"
"Language: cs\n"
"PO-Revision-Date: 2020-06-02 19:28+0000\n"
"Last-Translator: Pavel Solař <pavelsolar86@gmail.com>, 2021\n"
"Language-Team: Czech (https://www.transifex.com/django-recipes/teams/110507/cs/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n "
"<= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n"
"X-Generator: Weblate 4.15\n"
"Language: cs\n"
"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n"
#: .\cookbook\filters.py:22 .\cookbook\templates\base.html:87
#: .\cookbook\templates\forms\edit_internal_recipe.html:219
@@ -175,7 +173,7 @@ msgstr "Potravina, která by měla být nahrazena."
#: .\cookbook\forms.py:198
msgid "Add your comment: "
msgstr "Přidat vlastní komentář: "
msgstr "Přidat vlastní komentář:"
#: .\cookbook\forms.py:229
msgid "Leave empty for dropbox and enter app password for nextcloud."
@@ -553,7 +551,7 @@ msgstr "Cesta musí být v následujícím formátu"
#: .\cookbook\templates\batch\monitor.html:27
msgid "Sync Now!"
msgstr "Zahájit synchronizaci!"
msgstr "Zahájit synchronizaci"
#: .\cookbook\templates\batch\waiting.html:4
#: .\cookbook\templates\batch\waiting.html:10
@@ -1036,7 +1034,7 @@ msgstr "Tento text je kurzívou"
#: .\cookbook\templates\markdown_info.html:61
#: .\cookbook\templates\markdown_info.html:77
msgid "Blockquotes are also possible"
msgstr "Lze použít i kvotace"
msgstr "Lze použít i kvotace "
#: .\cookbook\templates\markdown_info.html:84
msgid "Lists"
@@ -1106,8 +1104,8 @@ msgid ""
"rel=\"noreferrer noopener\" target=\"_blank\">this one.</a>"
msgstr ""
"Ruční vytváření tabulek pomocí značek je složité. Doporučujeme použít "
"například <a href=\"https://www.tablesgenerator.com/markdown_tables\" rel="
"\"noreferrer noopener\" target=\"_blank\">tento tabulkový editor.</a>"
"například <a href=\"https://www.tablesgenerator.com/markdown_tables\" "
"rel=\"noreferrer noopener\" target=\"_blank\">tento tabulkový editor</a>."
#: .\cookbook\templates\markdown_info.html:155
#: .\cookbook\templates\markdown_info.html:157
@@ -1256,36 +1254,22 @@ msgid ""
" "
msgstr ""
"\n"
" <p>Modul jídelníčku umožňuje plánovat jídlo "
"pomocí receptů i poznámek.</p>\n"
" <p>Jednoduše vyberte recept ze seznamu naposledy "
"navštívených receptů, nebo ho vyhledejte\n"
" s přetáhněte na požadovaný den v rozvrhu. "
"Můžete také přidat poznámku s popiskem\n"
" a poté přetáhnout recept pro vytvoření plánu "
"s vlatními popisky. Vytvořem samotné poznámky\n"
" je možné přetažením pole poznámky do "
"rozvrhu.</p>\n"
" <p>Kliknutím na recept zobrazíte detailní "
"náhled. Odtud lze také přidat položky\n"
" do nákupního seznamu. Do nákupního seznamu "
"můžete také přidat všechny recepty na daný den\n"
" kliknutím na ikonu nákupního košíku na horní "
"straně tabulky.</p>\n"
" <p>V běžném případě se jídelníček plánuje "
"hromadně, proto můžete v nastavení definovat\n"
" se kterými uživateli si přejete jídelníčky "
"sdílet.\n"
" <p>Modul jídelníčku umožňuje plánovat jídlo pomocí receptů i poznámek.</p>\n"
" <p>Jednoduše vyberte recept ze seznamu naposledy navštívených receptů, nebo ho vyhledejte\n"
" s přetáhněte na požadovaný den v rozvrhu. Můžete také přidat poznámku s popiskem\n"
" a poté přetáhnout recept pro vytvoření plánu s vlatními popisky. Vytvořením samotné poznámky\n"
" je možné přetažením pole poznámky do rozvrhu.</p>\n"
" <p>Kliknutím na recept zobrazíte detailní náhled. Odtud lze také přidat položky\n"
" do nákupního seznamu. Do nákupního seznamu můžete také přidat všechny recepty na daný den\n"
" kliknutím na ikonu nákupního košíku na horní straně tabulky.</p>\n"
" <p>V běžnémípadě se jídelníček plánuje hromadně, proto můžete v nastavení definovat\n"
" se kterými uživateli si přejete jídelníčky sdílet.\n"
" </p>\n"
" <p>Můžete také upravovat typy jídel, které si "
"přejete naplánovat. Pokud budete sdílet jídelníček \n"
" <p>Můžete také upravovat typy jídel, které si přejete naplánovat. Pokud budete sdílet jídelníček \n"
" s někým, kdo\n"
" má přidána jiná jídla, jeho typy jídel se "
"objeví i ve vašem seznamu. Pro předcházení\n"
" má přidána jiná jídla, jeho typy jídel se objeví i ve vašem seznamu. Pro předcházení\n"
" duplicitám (např. Ostatní, Jiná)\n"
" pojmenujte váš typ jídla stejně, jako "
"uživatel se kterým své seznamy sdílíte. Tím budou seznamy\n"
" sloučeny.</p>\n"
" pojmenujte váš typ jídla stejně, jako uživatel se kterým své seznamy sdílíte. Tím budou seznamy sloučeny.</p>\n"
" "
#: .\cookbook\templates\meal_plan_entry.html:6
@@ -1347,12 +1331,12 @@ msgstr "Obrázek receptu"
#: .\cookbook\templates\recipes_table.html:46
#: .\cookbook\templates\url_import.html:55
msgid "Preparation time ca."
msgstr "Doba přípravy cca."
msgstr "Doba přípravy cca"
#: .\cookbook\templates\recipes_table.html:52
#: .\cookbook\templates\url_import.html:60
msgid "Waiting time ca."
msgstr "Doba čekání cca."
msgstr "Doba čekání cca"
#: .\cookbook\templates\recipes_table.html:55
msgid "External"
@@ -1400,7 +1384,7 @@ msgid ""
" in the following examples:"
msgstr ""
"Použijte tajný klíč jako autorizační hlavičku definovanou slovním klíčem, "
"jak je uvedeno v následujících příkladech:"
"jak je uvedeno v následujících příkladech."
#: .\cookbook\templates\settings.html:94
msgid "or"
@@ -1822,7 +1806,7 @@ msgstr "Import není pro tohoto poskytovatele implementován!"
#: .\cookbook\views\import_export.py:58
msgid "Exporting is not implemented for this provider"
msgstr "Export není pro tohoto poskytovatele implementován!"
msgstr "Eport není pro tohoto poskytovatele implementován!"
#: .\cookbook\views\lists.py:42
msgid "Import Log"
@@ -1854,7 +1838,7 @@ msgstr "Komentář uložen!"
#: .\cookbook\views\views.py:152
msgid "This recipe is already linked to the book!"
msgstr "Tento recept už v kuchařce existuje!"
msgstr "Tento recept už v kuchařce existuje."
#: .\cookbook\views\views.py:158
msgid "Bookmark saved!"

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-29 18:42+0200\n"
"PO-Revision-Date: 2023-04-12 11:55+0000\n"
"Last-Translator: noxonad <noxonad@proton.me>\n"
"PO-Revision-Date: 2022-08-18 14:32+0000\n"
"Last-Translator: Mathias Rasmussen <math625f@gmail.com>\n"
"Language-Team: Danish <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/da/>\n"
"Language: da\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.15\n"
"X-Generator: Weblate 4.10.1\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\forms\ingredients.html:34
#: .\cookbook\templates\space.html:49 .\cookbook\templates\stats.html:28
@@ -1806,7 +1806,7 @@ msgid ""
msgstr ""
" \n"
" Heltekstsøgning forsøger at normalisere de givne ord så de "
"matcher stammevarianter. F.eks: 'skeen', 'skeer' og 'sket' vil alt "
"matcher stammevarianter. F.eks: 'skeen', 'skeer' og 'sket' vil alt "
"normaliseres til 'ske'.\n"
" Der er flere metoder tilgængelige, beskrevet herunder, som vil "
"bestemme hvordan søgningen skal opfører sig når flere søgeord er angivet.\n"
@@ -2122,9 +2122,9 @@ msgid ""
"return more results than needed to make sure you find what you are looking "
"for."
msgstr ""
"Find hvad du har brug for, selvom opskriften har stavefejl. Kan måske "
"returnere flere resultater end du har brug for, for at være sikker på, at du "
"finder, hvad du leder efter."
"Find hvad du har brug for selvom opskriften har stavefejl. Kan måske "
"returnere flere resultater end du har brug for, for at være sikker på at du "
"finder hvad du leder efter."
#: .\cookbook\templates\settings.html:182
msgid "This is the default behavior"
@@ -2196,7 +2196,8 @@ msgid ""
"You can sign in to your account using any of the following third party\n"
" accounts:"
msgstr ""
"Du kan logge ind på din konto med enhver af de følgende tredjepartskontoer:"
"Du kan logge ind på din konto med enhver af de følgende tredjepartsapps\n"
" kontoer:"
#: .\cookbook\templates\socialaccount\connections.html:52
msgid ""
@@ -2211,7 +2212,7 @@ msgstr "Tilføj en tredjepartskonto"
#: .\cookbook\templates\socialaccount\signup.html:5
msgid "Signup"
msgstr "Registrer"
msgstr "Registrering"
#: .\cookbook\templates\socialaccount\signup.html:10
#, python-format

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-02-09 18:01+0100\n"
"PO-Revision-Date: 2023-01-08 17:55+0000\n"
"Last-Translator: Joachim Weber <joachim.weber@gmx.de>\n"
"PO-Revision-Date: 2021-10-13 12:50+0000\n"
"Last-Translator: Hrachya Kocharyan <hkocharyan@ctemplar.com>\n"
"Language-Team: Armenian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/hy/>\n"
"Language: hy\n"
@@ -20,7 +20,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Weblate 4.15\n"
"X-Generator: Weblate 4.8\n"
#: .\cookbook\filters.py:22 .\cookbook\templates\base.html:87
#: .\cookbook\templates\forms\edit_internal_recipe.html:219
@@ -410,7 +410,7 @@ msgstr "Դուրս գալ"
#: .\cookbook\templates\account\logout.html:11
msgid "Are you sure you want to sign out?"
msgstr "Համոզվա՞ծ եք, որ ցանկանում եք դուրս գալ՞"
msgstr "Համոզվա՞ծ եք, որ ցանկանում եք դուրս գալ:"
#: .\cookbook\templates\account\password_reset.html:5
#: .\cookbook\templates\account\password_reset_done.html:5

View File

@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-07-12 19:20+0200\n"
"PO-Revision-Date: 2022-10-12 08:33+0000\n"
"PO-Revision-Date: 2022-10-01 16:38+0000\n"
"Last-Translator: wella <wella.design@gmail.com>\n"
"Language-Team: Indonesian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/id/>\n"
@@ -155,180 +155,154 @@ msgstr "Kecualikan bahan-bahan yang ada."
#: .\cookbook\forms.py:90
msgid "Will optimize the UI for use with your left hand."
msgstr "Akan mengoptimalkan UI untuk digunakan dengan tangan kiri Anda."
msgstr ""
#: .\cookbook\forms.py:107
msgid ""
"Both fields are optional. If none are given the username will be displayed "
"instead"
msgstr ""
"Kedua bidang ini opsional. Jika tidak ada yang diberikan nama pengguna akan "
"ditampilkan sebagai gantinya"
#: .\cookbook\forms.py:128 .\cookbook\forms.py:301
msgid "Name"
msgstr "Nama"
msgstr ""
#: .\cookbook\forms.py:129 .\cookbook\forms.py:302
#: .\cookbook\templates\stats.html:24 .\cookbook\views\lists.py:88
msgid "Keywords"
msgstr "Kata Kunci"
msgstr ""
#: .\cookbook\forms.py:130
msgid "Preparation time in minutes"
msgstr "Waktu persiapan dalam hitungan menit"
msgstr ""
#: .\cookbook\forms.py:131
msgid "Waiting time (cooking/baking) in minutes"
msgstr "Waktu tunggu (memasak/memanggang) dalam hitungan menit"
msgstr ""
#: .\cookbook\forms.py:132 .\cookbook\forms.py:270 .\cookbook\forms.py:303
msgid "Path"
msgstr "Jalur"
msgstr ""
#: .\cookbook\forms.py:133
msgid "Storage UID"
msgstr "UID penyimpanan"
msgstr ""
#: .\cookbook\forms.py:165
msgid "Default"
msgstr "Bawaan"
msgstr ""
#: .\cookbook\forms.py:177
msgid ""
"To prevent duplicates recipes with the same name as existing ones are "
"ignored. Check this box to import everything."
msgstr ""
"Untuk mencegah duplikat resep dengan nama yang sama dengan yang sudah ada "
"diabaikan. Centang kotak ini untuk mengimpor semuanya."
#: .\cookbook\forms.py:200
msgid "Add your comment: "
msgstr "Tambahkan komentar Anda: "
msgstr ""
#: .\cookbook\forms.py:215
msgid "Leave empty for dropbox and enter app password for nextcloud."
msgstr ""
"Biarkan kosong untuk dropbox dan masukkan kata sandi aplikasi untuk "
"nextcloud."
#: .\cookbook\forms.py:222
msgid "Leave empty for nextcloud and enter api token for dropbox."
msgstr "Biarkan kosong untuk nextcloud dan masukkan token api untuk dropbox."
msgstr ""
#: .\cookbook\forms.py:231
msgid ""
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
"php/webdav/</code> is added automatically)"
msgstr ""
"Biarkan kosong untuk dropbox dan masukkan hanya url dasar untuk cloud "
"berikutnya (<code>/remote.php/webdav/</code> ditambahkan secara otomatis)"
#: .\cookbook\forms.py:269 .\cookbook\views\edit.py:157
msgid "Storage"
msgstr "Penyimpanan"
msgstr ""
#: .\cookbook\forms.py:271
msgid "Active"
msgstr "Aktif"
msgstr ""
#: .\cookbook\forms.py:277
msgid "Search String"
msgstr "Cari String"
msgstr ""
#: .\cookbook\forms.py:304
msgid "File ID"
msgstr "ID Berkas"
msgstr ""
#: .\cookbook\forms.py:326
msgid "You must provide at least a recipe or a title."
msgstr "Anda harus memberikan setidaknya resep atau judul."
msgstr ""
#: .\cookbook\forms.py:339
msgid "You can list default users to share recipes with in the settings."
msgstr ""
"Anda dapat membuat daftar pengguna default untuk berbagi resep di pengaturan."
#: .\cookbook\forms.py:340
msgid ""
"You can use markdown to format this field. See the <a href=\"/docs/markdown/"
"\">docs here</a>"
msgstr ""
"Anda dapat menggunakan penurunan harga untuk memformat bidang ini. Lihat <a "
"href=\"/docs/markdown/\">dokumen di sini</a>"
#: .\cookbook\forms.py:366
msgid "Maximum number of users for this space reached."
msgstr "Jumlah maksimum pengguna untuk ruang ini tercapai."
msgstr ""
#: .\cookbook\forms.py:372
msgid "Email address already taken!"
msgstr "Alamat email sudah terpakai!"
msgstr ""
#: .\cookbook\forms.py:380
msgid ""
"An email address is not required but if present the invite link will be sent "
"to the user."
msgstr ""
"Alamat email tidak diperlukan tetapi jika ada, tautan undangan akan dikirim "
"ke pengguna."
#: .\cookbook\forms.py:395
msgid "Name already taken."
msgstr "Nama sudah terpakai."
msgstr ""
#: .\cookbook\forms.py:406
msgid "Accept Terms and Privacy"
msgstr "Terima Persyaratan dan Privasi"
msgstr ""
#: .\cookbook\forms.py:438
msgid ""
"Determines how fuzzy a search is if it uses trigram similarity matching (e."
"g. low values mean more typos are ignored)."
msgstr ""
"Menentukan seberapa kabur pencarian jika menggunakan pencocokan kesamaan "
"trigram (misalnya nilai rendah berarti lebih banyak kesalahan ketik yang "
"diabaikan)."
#: .\cookbook\forms.py:448
msgid ""
"Select type method of search. Click <a href=\"/docs/search/\">here</a> for "
"full description of choices."
msgstr ""
"Pilih jenis metode pencarian. Klik <a href=\"/docs/search/\">di sini</a> "
"untuk deskripsi lengkap pilihan."
#: .\cookbook\forms.py:449
msgid ""
"Use fuzzy matching on units, keywords and ingredients when editing and "
"importing recipes."
msgstr ""
"Gunakan fuzzy pencocokan pada unit, kata kunci, dan bahan saat mengedit dan "
"mengimpor resep."
#: .\cookbook\forms.py:451
msgid ""
"Fields to search ignoring accents. Selecting this option can improve or "
"degrade search quality depending on language"
msgstr ""
"Bidang untuk mencari mengabaikan aksen. Memilih opsi ini dapat meningkatkan "
"atau menurunkan kualitas pencarian tergantung pada bahasa"
#: .\cookbook\forms.py:453
msgid ""
"Fields to search for partial matches. (e.g. searching for 'Pie' will return "
"'pie' and 'piece' and 'soapie')"
msgstr ""
"Bidang untuk mencari kecocokan sebagian. (mis. mencari 'Pie' akan "
"mengembalikan 'pie' dan 'piece' dan 'soapie')"
#: .\cookbook\forms.py:455
msgid ""
"Fields to search for beginning of word matches. (e.g. searching for 'sa' "
"will return 'salad' and 'sandwich')"
msgstr ""
"Bidang untuk mencari awal kata yang cocok. (misalnya mencari 'sa' akan "
"mengembalikan 'salad' dan 'sandwich')"
#: .\cookbook\forms.py:457
msgid ""

File diff suppressed because it is too large Load Diff

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: 2021-04-11 15:09+0200\n"
"PO-Revision-Date: 2023-04-17 20:55+0000\n"
"Last-Translator: Espen Sellevåg <buskmenn.drammer03@icloud.com>\n"
"PO-Revision-Date: 2021-04-11 15:23+0000\n"
"Last-Translator: Allan Nordhøy <epost@anotheragency.no>\n"
"Language-Team: Norwegian Bokmål <http://translate.tandoor.dev/projects/"
"tandoor/recipes-backend/nb_NO/>\n"
"Language: nb_NO\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.15\n"
"X-Generator: Weblate 4.5.3\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\base.html:91
#: .\cookbook\templates\forms\edit_internal_recipe.html:219
@@ -34,23 +34,19 @@ msgstr ""
#: .\cookbook\forms.py:46
msgid "Default Unit to be used when inserting a new ingredient into a recipe."
msgstr "Standard enhet når ny ingrediens legges til en oppskrift."
msgstr ""
#: .\cookbook\forms.py:47
msgid ""
"Enables support for fractions in ingredient amounts (e.g. convert decimals "
"to fractions automatically)"
msgstr ""
"Aktiverer støtte for deler av ingrediensmengde (konverterer feks. desimaler "
"til deler automatisk)"
#: .\cookbook\forms.py:48
msgid ""
"Users with whom newly created meal plan/shopping list entries should be "
"shared by default."
msgstr ""
"Brukere som oppretter nye måltidsplaner/handlelister, deler disse "
"oppføringene som standard."
#: .\cookbook\forms.py:49
msgid "Show recently viewed recipes on search page."
@@ -62,7 +58,7 @@ msgstr "Antall desimaler ingredienser skal avrundes til."
#: .\cookbook\forms.py:51
msgid "If you want to be able to create and see comments underneath recipes."
msgstr "Hvis du ønsker å opprette og se kommentarer under oppskrifter."
msgstr ""
#: .\cookbook\forms.py:53
msgid ""
@@ -71,11 +67,6 @@ msgid ""
"Useful when shopping with multiple people but might use a little bit of "
"mobile data. If lower than instance limit it is reset when saving."
msgstr ""
"0 vil deaktivere automatisk synkronisering. Når en handleliste vises, "
"oppdateres listen med oppgitt antall sekunders mellomrom for å synkronisere "
"endringer fra andre brukere. Nyttig dersom flere brukere handler samtidig. "
"Datatrafikk oppstår når aktiv. Hvis verdien er lavere enn grensen, "
"tilbakestilles den ved lagring."
#: .\cookbook\forms.py:56
msgid "Makes the navbar stick to the top of the page."
@@ -109,11 +100,11 @@ msgstr ""
#: .\cookbook\forms.py:97 .\cookbook\forms.py:317
msgid "Path"
msgstr "Sti"
msgstr ""
#: .\cookbook\forms.py:98
msgid "Storage UID"
msgstr "Lagring UID"
msgstr ""
#: .\cookbook\forms.py:121
msgid "Default"
@@ -138,6 +129,7 @@ msgid "Old Unit"
msgstr "Gammel enhet"
#: .\cookbook\forms.py:156
#, fuzzy
msgid "Unit that should be replaced."
msgstr "Enhet som skal erstattes."
@@ -212,11 +204,12 @@ msgstr ""
#: .\cookbook\views\views.py:112 .\cookbook\views\views.py:116
#: .\cookbook\views\views.py:184
msgid "You do not have the required permissions to view this page!"
msgstr "Du har ikke påkrevd tilgang for å vise denne siden!"
msgstr "Du har ikke påkrevd tilgang for å vise denne siden."
#: .\cookbook\helper\permission_helper.py:141
#, fuzzy
msgid "You are not logged in and therefore cannot view this page!"
msgstr "Du er ikke innlogget og kan derfor ikke vise siden!"
msgstr "Du er ikke innlogget og kan derfor ikke vise siden."
#: .\cookbook\helper\permission_helper.py:145
#: .\cookbook\helper\permission_helper.py:167
@@ -386,7 +379,7 @@ msgstr "Finner ikke siden du leter etter."
#: .\cookbook\templates\404.html:33
msgid "Take me Home"
msgstr "Tilbake til Startsiden"
msgstr ""
#: .\cookbook\templates\404.html:35
msgid "Report a Bug"
@@ -395,12 +388,12 @@ msgstr "Rapporter en feil"
#: .\cookbook\templates\account\login.html:7
#: .\cookbook\templates\base.html:170
msgid "Login"
msgstr "Logg inn"
msgstr ""
#: .\cookbook\templates\account\login.html:13
#: .\cookbook\templates\account\login.html:28
msgid "Sign In"
msgstr "Opprett bruker"
msgstr ""
#: .\cookbook\templates\account\login.html:38
msgid "Social Login"
@@ -408,7 +401,7 @@ msgstr "Sosial innlogging"
#: .\cookbook\templates\account\login.html:39
msgid "You can use any of the following providers to sign in."
msgstr "Velg en av følgende leverandører for å logge på."
msgstr ""
#: .\cookbook\templates\account\logout.html:5
#: .\cookbook\templates\account\logout.html:9
@@ -423,20 +416,20 @@ msgstr "Er du sikker på at du vil logge ut?"
#: .\cookbook\templates\account\password_reset.html:5
#: .\cookbook\templates\account\password_reset_done.html:5
msgid "Password Reset"
msgstr "Nullstill passord"
msgstr ""
#: .\cookbook\templates\account\password_reset.html:9
#: .\cookbook\templates\account\password_reset_done.html:9
msgid "Password reset is not implemented for the time being!"
msgstr "Det er foreløpig ikke implementert funksjon for å nullstille passord!"
msgstr ""
#: .\cookbook\templates\account\signup.html:5
msgid "Register"
msgstr "Registrer"
msgstr ""
#: .\cookbook\templates\account\signup.html:9
msgid "Create your Account"
msgstr "Opprett konto"
msgstr "Opprett din konto"
#: .\cookbook\templates\account\signup.html:14
msgid "Create User"
@@ -449,11 +442,11 @@ msgstr "API-dokumentasjon"
#: .\cookbook\templates\base.html:78
msgid "Utensils"
msgstr "Redskaper"
msgstr ""
#: .\cookbook\templates\base.html:88
msgid "Shopping"
msgstr "Handle"
msgstr ""
#: .\cookbook\templates\base.html:102 .\cookbook\views\delete.py:84
#: .\cookbook\views\edit.py:93 .\cookbook\views\lists.py:26
@@ -463,27 +456,27 @@ msgstr "Nøkkelord"
#: .\cookbook\templates\base.html:104
msgid "Batch Edit"
msgstr "Oppdatere flere"
msgstr ""
#: .\cookbook\templates\base.html:109
msgid "Storage Data"
msgstr "Datalagring"
msgstr ""
#: .\cookbook\templates\base.html:113
msgid "Storage Backends"
msgstr "Lagringsplasser"
msgstr ""
#: .\cookbook\templates\base.html:115
msgid "Configure Sync"
msgstr "Konfigurer synkronisering"
msgstr ""
#: .\cookbook\templates\base.html:117
msgid "Discovered Recipes"
msgstr "Oppdagede oppskrifter"
msgstr ""
#: .\cookbook\templates\base.html:119
msgid "Discovery Log"
msgstr "Logg Oppdagelser"
msgstr ""
#: .\cookbook\templates\base.html:121 .\cookbook\templates\stats.html:10
msgid "Statistics"
@@ -491,7 +484,7 @@ msgstr "Statistikk"
#: .\cookbook\templates\base.html:123
msgid "Units & Ingredients"
msgstr "Enheter & Ingredienser"
msgstr ""
#: .\cookbook\templates\base.html:125
msgid "Import Recipe"
@@ -528,61 +521,58 @@ msgid "API Browser"
msgstr "API-utforsker"
#: .\cookbook\templates\base.html:165
#, fuzzy
msgid "Logout"
msgstr "Logg ut"
#: .\cookbook\templates\batch\edit.html:6
msgid "Batch edit Category"
msgstr "Oppdater flere kategorier"
msgstr ""
#: .\cookbook\templates\batch\edit.html:15
msgid "Batch edit Recipes"
msgstr "Oppdater flere oppskrifter"
msgstr ""
#: .\cookbook\templates\batch\edit.html:20
msgid "Add the specified keywords to all recipes containing a word"
msgstr "Legg til spesifikt nøkkelord til alle oppskrifter som inneholder et ord"
msgstr ""
#: .\cookbook\templates\batch\monitor.html:6 .\cookbook\views\edit.py:76
msgid "Sync"
msgstr "Synkronisering"
msgstr ""
#: .\cookbook\templates\batch\monitor.html:10
msgid "Manage watched Folders"
msgstr "Behandle overvåkede mapper"
msgstr ""
#: .\cookbook\templates\batch\monitor.html:14
msgid ""
"On this Page you can manage all storage folder locations that should be "
"monitored and synced."
msgstr ""
"Her kan du behandle alle lagringsmapper og plasseringer for monitorering og "
"synkronisering."
#: .\cookbook\templates\batch\monitor.html:16
msgid "The path must be in the following format"
msgstr "Stien må være i følgende format"
msgstr ""
#: .\cookbook\templates\batch\monitor.html:27
msgid "Sync Now!"
msgstr "Synkroniser nå!"
msgstr ""
#: .\cookbook\templates\batch\waiting.html:4
#: .\cookbook\templates\batch\waiting.html:10
msgid "Importing Recipes"
msgstr "Importerer oppskrifter"
msgstr ""
#: .\cookbook\templates\batch\waiting.html:23
msgid ""
"This can take a few minutes, depending on the number of recipes in sync, "
"please wait."
msgstr ""
"Dette kan ta noen minutter, avhenging av antall oppskrifter som skal "
"synkroniseres. Vennligst vent."
#: .\cookbook\templates\books.html:5 .\cookbook\templates\books.html:11
msgid "Recipe Books"
msgstr "Oppskriftsbøker"
msgstr ""
#: .\cookbook\templates\books.html:15
msgid "New Book"
@@ -594,32 +584,32 @@ msgstr "av"
#: .\cookbook\templates\books.html:34
msgid "Toggle Recipes"
msgstr "Veksle oppskrifter"
msgstr ""
#: .\cookbook\templates\books.html:54
#: .\cookbook\templates\meal_plan_entry.html:48
#: .\cookbook\templates\recipes_table.html:64
msgid "Last cooked"
msgstr "Forrige tilbereding"
msgstr ""
#: .\cookbook\templates\books.html:71
msgid "There are no recipes in this book yet."
msgstr "Det er foreløpig ingen oppskrifter i denne boken."
msgstr ""
#: .\cookbook\templates\export.html:6 .\cookbook\templates\test2.html:6
msgid "Export Recipes"
msgstr "Eksporter oppskrifter"
msgstr ""
#: .\cookbook\templates\export.html:14 .\cookbook\templates\export.html:20
#: .\cookbook\templates\shopping_list.html:347
#: .\cookbook\templates\test2.html:14 .\cookbook\templates\test2.html:20
msgid "Export"
msgstr "Eksporter"
msgstr ""
#: .\cookbook\templates\forms\edit_import_recipe.html:5
#: .\cookbook\templates\forms\edit_import_recipe.html:9
msgid "Import new Recipe"
msgstr "Importer ny oppskrift"
msgstr ""
#: .\cookbook\templates\forms\edit_import_recipe.html:14
#: .\cookbook\templates\forms\edit_internal_recipe.html:389
@@ -645,29 +635,29 @@ msgstr "Beskrivelse"
#: .\cookbook\templates\forms\edit_internal_recipe.html:72
msgid "Waiting Time"
msgstr "Ventetid"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:78
msgid "Servings Text"
msgstr "Porsjon beskrivelse"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:89
msgid "Select Keywords"
msgstr "Velg nøkkelord"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:90
#: .\cookbook\templates\url_import.html:212
msgid "Add Keyword"
msgstr "Legg til nøkkelord"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:108
msgid "Nutrition"
msgstr "Næringsinnhold"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:112
#: .\cookbook\templates\forms\edit_internal_recipe.html:162
msgid "Delete Step"
msgstr "Fjern trinn"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:116
msgid "Calories"
@@ -688,15 +678,15 @@ msgstr "Proteiner"
#: .\cookbook\templates\forms\edit_internal_recipe.html:146
#: .\cookbook\templates\forms\edit_internal_recipe.html:454
msgid "Step"
msgstr "Trinn"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:167
msgid "Show as header"
msgstr "Vis som overskrift"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:173
msgid "Hide as header"
msgstr "Skjul overskrift"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:178
msgid "Move Up"
@@ -708,15 +698,15 @@ msgstr "Flytt nedover"
#: .\cookbook\templates\forms\edit_internal_recipe.html:192
msgid "Step Name"
msgstr "Trinn navn"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:196
msgid "Step Type"
msgstr "Trinn type"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:207
msgid "Step time in Minutes"
msgstr "Trinn tid i minutter"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:261
#: .\cookbook\templates\shopping_list.html:183
@@ -750,7 +740,7 @@ msgstr "Velg mat"
#: .\cookbook\templates\meal_plan.html:256
#: .\cookbook\templates\url_import.html:171
msgid "Note"
msgstr "Notis"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:319
msgid "Delete Ingredient"
@@ -758,7 +748,7 @@ msgstr "Slett ingrediens"
#: .\cookbook\templates\forms\edit_internal_recipe.html:325
msgid "Make Header"
msgstr "Bruk som overskrift"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:331
msgid "Make Ingredient"
@@ -766,15 +756,15 @@ msgstr "Opprett ingrediens"
#: .\cookbook\templates\forms\edit_internal_recipe.html:337
msgid "Disable Amount"
msgstr "Deaktiver mengde"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:343
msgid "Enable Amount"
msgstr "Aktiver mengde"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:348
msgid "Copy Template Reference"
msgstr "Kopier mal-referanse"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:374
#: .\cookbook\templates\url_import.html:196
@@ -783,28 +773,29 @@ msgstr "Instruksjoner"
#: .\cookbook\templates\forms\edit_internal_recipe.html:387
#: .\cookbook\templates\forms\edit_internal_recipe.html:418
#, fuzzy
msgid "Save & View"
msgstr "Lagre og vis"
#: .\cookbook\templates\forms\edit_internal_recipe.html:391
#: .\cookbook\templates\forms\edit_internal_recipe.html:424
msgid "Add Step"
msgstr "Legg til trinn"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:394
#: .\cookbook\templates\forms\edit_internal_recipe.html:428
msgid "Add Nutrition"
msgstr "Legg til næringsinnhold"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:396
#: .\cookbook\templates\forms\edit_internal_recipe.html:430
msgid "Remove Nutrition"
msgstr "Fjern næringsinnhold"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:398
#: .\cookbook\templates\forms\edit_internal_recipe.html:433
msgid "View Recipe"
msgstr "Vis oppskrift"
msgstr ""
#: .\cookbook\templates\forms\edit_internal_recipe.html:400
#: .\cookbook\templates\forms\edit_internal_recipe.html:435
@@ -813,11 +804,11 @@ msgstr "Slett oppskrift"
#: .\cookbook\templates\forms\edit_internal_recipe.html:441
msgid "Steps"
msgstr "Trinn"
msgstr ""
#: .\cookbook\templates\forms\ingredients.html:15
msgid "Edit Ingredients"
msgstr "Rediger ingrediens"
msgstr ""
#: .\cookbook\templates\forms\ingredients.html:16
msgid ""
@@ -829,61 +820,54 @@ msgid ""
"them.\n"
" "
msgstr ""
"\n"
" Følgende skjema kan brukes dersom, tilfeldigvis, to eller flere "
"enheter eller ingredienser er opprettet,\n"
" og burde være identiske.\n"
" Det slår sammen to enheter eller ingredienser og oppdaterer alle "
"oppskrifter som inneholder disse.\n"
" "
#: .\cookbook\templates\forms\ingredients.html:24
#: .\cookbook\templates\stats.html:26
msgid "Units"
msgstr "Enheter"
msgstr ""
#: .\cookbook\templates\forms\ingredients.html:26
msgid "Are you sure that you want to merge these two units?"
msgstr "Er du sikker på at du vil slå sammen disse enhetene?"
msgstr ""
#: .\cookbook\templates\forms\ingredients.html:31
#: .\cookbook\templates\forms\ingredients.html:40
msgid "Merge"
msgstr "Slå sammen"
msgstr "Flett"
#: .\cookbook\templates\forms\ingredients.html:36
msgid "Are you sure that you want to merge these two ingredients?"
msgstr "Er du sikker på at du vil slå sammen disse ingrediensene?"
msgstr ""
#: .\cookbook\templates\generic\delete_template.html:18
#, python-format
msgid "Are you sure you want to delete the %(title)s: <b>%(object)s</b> "
msgstr "Er du sikker på at du vil slette %(title)s: <b>%(object)s</b> "
msgstr ""
#: .\cookbook\templates\generic\delete_template.html:21
msgid "Confirm"
msgstr "Bekreft"
msgstr ""
#: .\cookbook\templates\generic\edit_template.html:30
msgid "View"
msgstr "Vis"
msgstr ""
#: .\cookbook\templates\generic\edit_template.html:34
msgid "Delete original file"
msgstr "Slett opprinnelig fil"
msgstr ""
#: .\cookbook\templates\generic\list_template.html:6
#: .\cookbook\templates\generic\list_template.html:12
msgid "List"
msgstr "Liste"
msgstr ""
#: .\cookbook\templates\generic\list_template.html:25
msgid "Filter"
msgstr "Filtrer"
msgstr ""
#: .\cookbook\templates\generic\list_template.html:30
msgid "Import all"
msgstr "Importer alle"
msgstr ""
#: .\cookbook\templates\generic\new_template.html:6
#: .\cookbook\templates\generic\new_template.html:14
@@ -907,19 +891,19 @@ msgstr "Vis logg"
#: .\cookbook\templates\history.html:24
msgid "Cook Log"
msgstr "Tilberedingslogg"
msgstr ""
#: .\cookbook\templates\import.html:6 .\cookbook\templates\test.html:6
msgid "Import Recipes"
msgstr "Importer oppskrifter"
msgstr ""
#: .\cookbook\templates\include\log_cooking.html:7
msgid "Log Recipe Cooking"
msgstr "Loggfør tilberedt oppskrift"
msgstr ""
#: .\cookbook\templates\include\log_cooking.html:13
msgid "All fields are optional and can be left empty."
msgstr "Alle felt er valgfri og kan stå tomme."
msgstr ""
#: .\cookbook\templates\include\log_cooking.html:19
msgid "Rating"
@@ -959,53 +943,44 @@ msgid ""
"can be used.\n"
" "
msgstr ""
"\n"
" <b>Passord og nøkkelfeltene</b> er lagret som <b>ren tekst</b> i "
"databasen.\n"
" Dette er nødvendig for å kunne utføre API-forespørsler, men det øker "
"samtidig risiko for\n"
" uønsket tilgang til dem.<br/>\n"
" For å begrense kosekvensene av uønsket tilgang, kan nøkler eller "
"kontoer med begrenset tilgang benyttes.\n"
" "
#: .\cookbook\templates\index.html:29
msgid "Search recipe ..."
msgstr "Søk etter oppskrift..."
msgstr ""
#: .\cookbook\templates\index.html:44
msgid "New Recipe"
msgstr "Ny oppskrift"
msgstr ""
#: .\cookbook\templates\index.html:47
msgid "Website Import"
msgstr "Importer fra nettside"
msgstr ""
#: .\cookbook\templates\index.html:53
msgid "Advanced Search"
msgstr "Avansert søk"
msgstr ""
#: .\cookbook\templates\index.html:57
msgid "Reset Search"
msgstr "Nullstill søk"
msgstr ""
#: .\cookbook\templates\index.html:85
msgid "Last viewed"
msgstr "Sist sett"
msgstr ""
#: .\cookbook\templates\index.html:87 .\cookbook\templates\meal_plan.html:178
#: .\cookbook\templates\stats.html:22
msgid "Recipes"
msgstr "Oppskrifter"
msgstr ""
#: .\cookbook\templates\index.html:94
msgid "Log in to view recipes"
msgstr "Logg inn for å se oppskrifter"
msgstr ""
#: .\cookbook\templates\markdown_info.html:5
#: .\cookbook\templates\markdown_info.html:13
msgid "Markdown Info"
msgstr "Markdown informasjon"
msgstr ""
#: .\cookbook\templates\markdown_info.html:14
msgid ""
@@ -1022,56 +997,43 @@ msgid ""
"below.\n"
" "
msgstr ""
"\n"
" Markdown er et lettvekts markup språk som benyttes for å formatere "
"ren tekst.\n"
" Denne siden bruker biblioteket <a href=\"https://python-markdown."
"github.io/\" target=\"_blank\">Python Markdown</a> for\n"
" å konvertere teksten din til velformatert HTML. Fullstendig "
"dokumentasjon for markdown finner du\n"
" <a href=\"https://daringfireball.net/projects/markdown/syntax\" "
"target=\"_blank\">her</a>.\n"
" En ufullstendig, men sannsynligvis tilstrekkelig dokumentasjon "
"finner du under her.\n"
" "
#: .\cookbook\templates\markdown_info.html:25
msgid "Headers"
msgstr "Overskrifter"
msgstr ""
#: .\cookbook\templates\markdown_info.html:54
msgid "Formatting"
msgstr "Formatering"
msgstr ""
#: .\cookbook\templates\markdown_info.html:56
#: .\cookbook\templates\markdown_info.html:72
msgid "Line breaks are inserted by adding two spaces after the end of a line"
msgstr ""
"Linjeskift er satt inn ved å sette inn to mellomrom på slutten av en linje"
#: .\cookbook\templates\markdown_info.html:57
#: .\cookbook\templates\markdown_info.html:73
msgid "or by leaving a blank line inbetween."
msgstr "eller ved å sette inn en tom linje mellom."
msgstr ""
#: .\cookbook\templates\markdown_info.html:59
#: .\cookbook\templates\markdown_info.html:74
msgid "This text is bold"
msgstr "Denne teksten er Fet"
msgstr ""
#: .\cookbook\templates\markdown_info.html:60
#: .\cookbook\templates\markdown_info.html:75
msgid "This text is italic"
msgstr "Denne teksten er Kursiv"
msgstr ""
#: .\cookbook\templates\markdown_info.html:61
#: .\cookbook\templates\markdown_info.html:77
msgid "Blockquotes are also possible"
msgstr "Det er også mulig å sitere avsnitt"
msgstr ""
#: .\cookbook\templates\markdown_info.html:84
msgid "Lists"
msgstr "Lister"
msgstr ""
#: .\cookbook\templates\markdown_info.html:85
msgid ""
@@ -1302,7 +1264,7 @@ msgstr ""
#: .\cookbook\templates\no_groups_info.html:5
#: .\cookbook\templates\no_groups_info.html:12
msgid "No Permissions"
msgstr "Ingen tilgang"
msgstr "Ingen tilganger."
#: .\cookbook\templates\no_groups_info.html:17
msgid "You do not have any groups and therefor cannot use this application."
@@ -1336,11 +1298,12 @@ msgstr ""
#: .\cookbook\templates\offline.html:6
msgid "Offline"
msgstr "Frakoblet"
msgstr "Frakoblet."
#: .\cookbook\templates\offline.html:19
#, fuzzy
msgid "You are currently offline!"
msgstr "Du er ikke tilkoblet!"
msgstr "Du er ikke tilkoblet Internett."
#: .\cookbook\templates\offline.html:20
msgid ""
@@ -1403,7 +1366,7 @@ msgstr "Stil"
#: .\cookbook\templates\settings.html:79
msgid "API Token"
msgstr "API nøkkel"
msgstr "API-symbol"
#: .\cookbook\templates\settings.html:80
msgid ""
@@ -1426,8 +1389,9 @@ msgid "Cookbook Setup"
msgstr "Kokeboksoppsett"
#: .\cookbook\templates\setup.html:14
#, fuzzy
msgid "Setup"
msgstr "Installering"
msgstr "Sett opp"
#: .\cookbook\templates\setup.html:15
msgid ""
@@ -1460,11 +1424,11 @@ msgstr "Mengde"
#: .\cookbook\templates\shopping_list.html:226
msgid "Supermarket"
msgstr "Butikk"
msgstr "Matbutikk"
#: .\cookbook\templates\shopping_list.html:236
msgid "Select Supermarket"
msgstr "Velg butikk"
msgstr "Velg matbutikk"
#: .\cookbook\templates\shopping_list.html:260
msgid "Select User"
@@ -1576,6 +1540,7 @@ msgstr ""
#: .\cookbook\templates\system.html:49 .\cookbook\templates\system.html:64
#: .\cookbook\templates\system.html:80 .\cookbook\templates\system.html:95
#, fuzzy
msgid "Ok"
msgstr "OK"

File diff suppressed because it is too large Load Diff

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: 2022-02-11 08:52+0100\n"
"PO-Revision-Date: 2023-04-12 11:55+0000\n"
"Last-Translator: noxonad <noxonad@proton.me>\n"
"PO-Revision-Date: 2022-03-08 01:31+0000\n"
"Last-Translator: Felipe Castro <felipefcastro@gmail.com>\n"
"Language-Team: Portuguese (Brazil) <http://translate.tandoor.dev/projects/"
"tandoor/recipes-backend/pt_BR/>\n"
"Language: pt_BR\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 4.15\n"
"X-Generator: Weblate 4.10.1\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\forms\ingredients.html:34
#: .\cookbook\templates\space.html:50 .\cookbook\templates\stats.html:28
@@ -158,7 +158,7 @@ msgstr ""
#: .\cookbook\templates\url_import.html:195
#: .\cookbook\templates\url_import.html:585 .\cookbook\views\lists.py:97
msgid "Keywords"
msgstr "Palavras-chave"
msgstr ""
#: .\cookbook\forms.py:131
msgid "Preparation time in minutes"
@@ -513,7 +513,7 @@ msgstr ""
#: .\cookbook\templates\url_import.html:231
#: .\cookbook\templates\url_import.html:462
msgid "Servings"
msgstr "Porções"
msgstr ""
#: .\cookbook\integration\saffron.py:25
msgid "Waiting time"
@@ -585,7 +585,7 @@ msgstr ""
#: .\cookbook\models.py:302 .\cookbook\templates\base.html:90
msgid "Books"
msgstr "Livros"
msgstr ""
#: .\cookbook\models.py:310
msgid "Small"
@@ -598,7 +598,7 @@ msgstr ""
#: .\cookbook\models.py:310 .\cookbook\templates\generic\new_template.html:6
#: .\cookbook\templates\generic\new_template.html:14
msgid "New"
msgstr "Novo"
msgstr ""
#: .\cookbook\models.py:513
msgid " is part of a recipe step and cannot be deleted"
@@ -677,7 +677,7 @@ msgstr ""
#: .\cookbook\templates\shopping_list.html:37
#: .\cookbook\templates\space.html:109
msgid "Edit"
msgstr "Editar"
msgstr ""
#: .\cookbook\tables.py:115 .\cookbook\tables.py:138
#: .\cookbook\templates\generic\delete_template.html:7
@@ -715,7 +715,7 @@ msgstr ""
#: .\cookbook\templates\settings.html:17
#: .\cookbook\templates\socialaccount\connections.html:10
msgid "Settings"
msgstr "Configurações"
msgstr ""
#: .\cookbook\templates\account\email.html:13
msgid "Email"
@@ -937,7 +937,7 @@ msgstr ""
#: .\cookbook\templates\account\signup.html:48
#: .\cookbook\templates\socialaccount\signup.html:39
msgid "and"
msgstr "e"
msgstr ""
#: .\cookbook\templates\account\signup.html:52
#: .\cookbook\templates\socialaccount\signup.html:43
@@ -989,7 +989,7 @@ msgstr ""
#: .\cookbook\templates\shopping_list.html:208
#: .\cookbook\templates\supermarket.html:7
msgid "Supermarket"
msgstr "Supermercado"
msgstr ""
#: .\cookbook\templates\base.html:163
msgid "Supermarket Category"
@@ -1027,7 +1027,7 @@ msgstr ""
#: .\cookbook\templates\shopping_list.html:165
#: .\cookbook\templates\shopping_list.html:188
msgid "Create"
msgstr "Criar"
msgstr ""
#: .\cookbook\templates\base.html:259
#: .\cookbook\templates\generic\list_template.html:14
@@ -1190,7 +1190,7 @@ msgstr ""
#: .\cookbook\templates\generic\delete_template.html:26
msgid "Protected"
msgstr "Protegido"
msgstr ""
#: .\cookbook\templates\generic\delete_template.html:41
msgid "Cascade"
@@ -1268,7 +1268,7 @@ msgstr ""
#: .\cookbook\templates\include\recipe_open_modal.html:18
msgid "Close"
msgstr "Fechar"
msgstr ""
#: .\cookbook\templates\include\recipe_open_modal.html:32
msgid "Open Recipe"
@@ -1821,7 +1821,7 @@ msgstr ""
#: .\cookbook\templates\settings.html:162
msgid "or"
msgstr "ou"
msgstr ""
#: .\cookbook\templates\settings.html:173
msgid ""
@@ -2062,7 +2062,7 @@ msgstr ""
#: .\cookbook\templates\space.html:120
msgid "user"
msgstr "usuário"
msgstr ""
#: .\cookbook\templates\space.html:121
msgid "guest"
@@ -2208,7 +2208,7 @@ msgstr ""
#: .\cookbook\templates\url_import.html:38
msgid "URL"
msgstr "URL"
msgstr ""
#: .\cookbook\templates\url_import.html:40
msgid "App"
@@ -2273,7 +2273,7 @@ msgstr ""
#: .\cookbook\templates\url_import.html:214
msgid "Image"
msgstr "Imagem"
msgstr ""
#: .\cookbook\templates\url_import.html:246
msgid "Prep Time"
@@ -2359,7 +2359,7 @@ msgstr ""
#: .\cookbook\templates\url_import.html:640
msgid "Information"
msgstr "Informação"
msgstr ""
#: .\cookbook\templates\url_import.html:642
msgid ""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,17 +8,17 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-09-13 22:40+0200\n"
"PO-Revision-Date: 2023-05-01 07:55+0000\n"
"Last-Translator: axeron2036 <admin@axeron2036.ru>\n"
"PO-Revision-Date: 2022-04-07 19:32+0000\n"
"Last-Translator: Artem Aksenov <artemmillerr@gmail.com>\n"
"Language-Team: Russian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/ru/>\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"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%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 4.15\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 4.10.1\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\base.html:125
#: .\cookbook\templates\forms\ingredients.html:34
@@ -286,7 +286,7 @@ msgstr ""
#: .\cookbook\forms.py:497
msgid "Search Method"
msgstr "Способ поиска"
msgstr ""
#: .\cookbook\forms.py:498
msgid "Fuzzy Lookups"
@@ -396,9 +396,8 @@ msgstr ""
#: .\cookbook\templates\include\log_cooking.html:16
#: .\cookbook\templates\url_import.html:224
#: .\cookbook\templates\url_import.html:455
#, fuzzy
msgid "Servings"
msgstr "Порции"
msgstr ""
#: .\cookbook\integration\safron.py:25
msgid "Waiting time"
@@ -469,7 +468,7 @@ msgstr ""
#: .\cookbook\models.py:198 .\cookbook\templates\base.html:90
msgid "Books"
msgstr "Книги"
msgstr ""
#: .\cookbook\models.py:206
msgid "Small"
@@ -861,7 +860,7 @@ msgstr ""
#: .\cookbook\templates\base.html:220
msgid "GitHub"
msgstr "GitHub"
msgstr ""
#: .\cookbook\templates\base.html:224
msgid "API Browser"
@@ -1937,7 +1936,7 @@ msgstr ""
#: .\cookbook\templates\space.html:106
msgid "user"
msgstr "пользователь"
msgstr ""
#: .\cookbook\templates\space.html:107
msgid "guest"

View File

@@ -8,17 +8,17 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"PO-Revision-Date: 2023-04-12 11:55+0000\n"
"Last-Translator: noxonad <noxonad@proton.me>\n"
"PO-Revision-Date: 2022-02-02 15:31+0000\n"
"Last-Translator: Mario Dvorsek <mario.dvorsek@gmail.com>\n"
"Language-Team: Slovenian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/sl/>\n"
"Language: sl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || "
"n%100==4 ? 2 : 3;\n"
"X-Generator: Weblate 4.15\n"
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n"
"%100==4 ? 2 : 3;\n"
"X-Generator: Weblate 4.10.1\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\base.html:125
#: .\cookbook\templates\forms\ingredients.html:34
@@ -2107,7 +2107,7 @@ msgstr ""
#: .\cookbook\templates\url_import.html:36
msgid "URL"
msgstr "URL"
msgstr ""
#: .\cookbook\templates\url_import.html:38
msgid "App"

File diff suppressed because it is too large Load Diff

View File

@@ -8,17 +8,15 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-29 18:42+0200\n"
"PO-Revision-Date: 2023-04-12 11:55+0000\n"
"Last-Translator: noxonad <noxonad@proton.me>\n"
"Language-Team: Ukrainian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/uk/>\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
"Language: uk\n"
"MIME-Version: 1.0\n"
"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%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 4.15\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\forms\ingredients.html:34
#: .\cookbook\templates\space.html:49 .\cookbook\templates\stats.html:28
@@ -1091,7 +1089,7 @@ msgstr ""
#: .\cookbook\templates\base.html:311
msgid "GitHub"
msgstr "GitHub"
msgstr ""
#: .\cookbook\templates\base.html:313
msgid "Translate Tandoor"
@@ -2028,7 +2026,7 @@ msgstr ""
#: .\cookbook\templates\space.html:118
msgid "user"
msgstr "користувач"
msgstr ""
#: .\cookbook\templates\space.html:119
msgid "guest"

File diff suppressed because it is too large Load Diff

View File

@@ -8,16 +8,14 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-06-12 20:30+0200\n"
"PO-Revision-Date: 2023-03-12 02:55+0000\n"
"Last-Translator: Feng Zhong <fewoodse@gmail.com>\n"
"Language-Team: Chinese (Traditional) <http://translate.tandoor.dev/projects/"
"tandoor/recipes-backend/zh_Hant/>\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\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 4.15\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\base.html:98
#: .\cookbook\templates\forms\edit_internal_recipe.html:246
@@ -25,41 +23,41 @@ msgstr ""
#: .\cookbook\templates\space.html:37 .\cookbook\templates\stats.html:28
#: .\cookbook\templates\url_import.html:270 .\cookbook\views\lists.py:67
msgid "Ingredients"
msgstr "食材"
msgstr ""
#: .\cookbook\forms.py:49
msgid ""
"Color of the top navigation bar. Not all colors work with all themes, just "
"try them out!"
msgstr "頂部導航欄的顏色。並非所有的顏色都適用於所有的主題,只要試一試就可以了!"
msgstr ""
#: .\cookbook\forms.py:51
msgid "Default Unit to be used when inserting a new ingredient into a recipe."
msgstr "在菜譜中插入新食材時使用的默認單位。"
msgstr ""
#: .\cookbook\forms.py:53
msgid ""
"Enables support for fractions in ingredient amounts (e.g. convert decimals "
"to fractions automatically)"
msgstr "啟用對食材數量的分數支持(例如自動將小數轉換為分數)"
msgstr ""
#: .\cookbook\forms.py:56
msgid ""
"Users with whom newly created meal plan/shopping list entries should be "
"shared by default."
msgstr "默認情況下,將自動與用戶共享新創建的膳食計劃。"
msgstr ""
#: .\cookbook\forms.py:58
msgid "Show recently viewed recipes on search page."
msgstr "在搜索頁面上查看最近看過的食譜。"
msgstr ""
#: .\cookbook\forms.py:59
msgid "Number of decimals to round ingredients."
msgstr "四舍五入食材的小數點數量。"
msgstr ""
#: .\cookbook\forms.py:60
msgid "If you want to be able to create and see comments underneath recipes."
msgstr "如果你希望能夠在菜譜下面創建並看到評論。"
msgstr ""
#: .\cookbook\forms.py:62
msgid ""
@@ -68,25 +66,22 @@ msgid ""
"Useful when shopping with multiple people but might use a little bit of "
"mobile data. If lower than instance limit it is reset when saving."
msgstr ""
"設置為0將禁用自動同步。當查看購物清單時清單會每隔幾秒鐘更新一次以同步其他"
"人可能做出的改變。在與多人一起購物時很有用,但可能會消耗一點移動數據。如果低"
"於實例限制,它將在保存時被重置。"
#: .\cookbook\forms.py:65
msgid "Makes the navbar stick to the top of the page."
msgstr "使導航欄保持在頁面的頂部。"
msgstr ""
#: .\cookbook\forms.py:81
msgid ""
"Both fields are optional. If none are given the username will be displayed "
"instead"
msgstr "這兩個字段都是可選的。如果沒有輸入,將顯示用戶名"
msgstr ""
#: .\cookbook\forms.py:102 .\cookbook\forms.py:331
#: .\cookbook\templates\forms\edit_internal_recipe.html:49
#: .\cookbook\templates\url_import.html:154
msgid "Name"
msgstr "名字"
msgstr ""
#: .\cookbook\forms.py:103 .\cookbook\forms.py:332
#: .\cookbook\templates\base.html:105
@@ -95,37 +90,37 @@ msgstr "名字"
#: .\cookbook\templates\url_import.html:188
#: .\cookbook\templates\url_import.html:573
msgid "Keywords"
msgstr "關鍵詞"
msgstr ""
#: .\cookbook\forms.py:104
msgid "Preparation time in minutes"
msgstr "準備時間(分鐘)"
msgstr ""
#: .\cookbook\forms.py:105
msgid "Waiting time (cooking/baking) in minutes"
msgstr "等候(烹飪、烘焙等)時間(分鐘)"
msgstr ""
#: .\cookbook\forms.py:106 .\cookbook\forms.py:333
msgid "Path"
msgstr "路徑"
msgstr ""
#: .\cookbook\forms.py:107
msgid "Storage UID"
msgstr "存儲ID"
msgstr ""
#: .\cookbook\forms.py:133
msgid "Default"
msgstr "默認"
msgstr ""
#: .\cookbook\forms.py:144 .\cookbook\templates\url_import.html:90
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:164
msgid "New Unit"
msgstr "新單位"
msgstr ""
#: .\cookbook\forms.py:165
msgid "New unit that other gets replaced by."
@@ -133,15 +128,15 @@ msgstr ""
#: .\cookbook\forms.py:170
msgid "Old Unit"
msgstr "舊單位"
msgstr ""
#: .\cookbook\forms.py:171
msgid "Unit that should be replaced."
msgstr "該被替換的單位。"
msgstr ""
#: .\cookbook\forms.py:187
msgid "New Food"
msgstr "新食物"
msgstr ""
#: .\cookbook\forms.py:188
msgid "New food that other gets replaced by."
@@ -149,86 +144,85 @@ msgstr ""
#: .\cookbook\forms.py:193
msgid "Old Food"
msgstr "舊食物"
msgstr ""
#: .\cookbook\forms.py:194
msgid "Food that should be replaced."
msgstr "該被替換的食物。"
msgstr ""
#: .\cookbook\forms.py:212
msgid "Add your comment: "
msgstr "發表評論。 "
msgstr ""
#: .\cookbook\forms.py:253
msgid "Leave empty for dropbox and enter app password for nextcloud."
msgstr "Dropbox 留空並輸入 Nextcloud 應用密碼。"
msgstr ""
#: .\cookbook\forms.py:260
msgid "Leave empty for nextcloud and enter api token for dropbox."
msgstr "Nextcloud 留空並輸入 Dropbox API 令牌。"
msgstr ""
#: .\cookbook\forms.py:269
msgid ""
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
"php/webdav/</code> is added automatically)"
msgstr "Dropbox 留空並輸入基礎 Nextcloud 網址(<code>/remote.php/webdav/</code> "
"會自動添加)"
msgstr ""
#: .\cookbook\forms.py:307
msgid "Search String"
msgstr "搜索字符串"
msgstr ""
#: .\cookbook\forms.py:334
msgid "File ID"
msgstr "文件編號"
msgstr ""
#: .\cookbook\forms.py:370
msgid "You must provide at least a recipe or a title."
msgstr "你必須至少提供一份菜譜或一個標題。"
msgstr ""
#: .\cookbook\forms.py:383
msgid "You can list default users to share recipes with in the settings."
msgstr "你可以在設置中列出默認用戶來分享菜譜。"
msgstr ""
#: .\cookbook\forms.py:384
#: .\cookbook\templates\forms\edit_internal_recipe.html:404
msgid ""
"You can use markdown to format this field. See the <a href=\"/docs/markdown/"
"\">docs here</a>"
msgstr "可以使用 Markdown 設置此字段格式。<a href=\"/docs/markdown/\">查看文檔</a>"
msgstr ""
#: .\cookbook\forms.py:409
msgid "Maximum number of users for this space reached."
msgstr "已達到該空間的最大用戶數。"
msgstr ""
#: .\cookbook\forms.py:415
msgid "Email address already taken!"
msgstr "電子郵件地址已被註冊!"
msgstr ""
#: .\cookbook\forms.py:423
msgid ""
"An email address is not required but if present the invite link will be send "
"to the user."
msgstr "電子郵件地址不是必需的,但如果存在,邀請鏈接將被發送給用戶。"
msgstr ""
#: .\cookbook\forms.py:438
msgid "Name already taken."
msgstr "名字已被占用。"
msgstr ""
#: .\cookbook\forms.py:449
msgid "Accept Terms and Privacy"
msgstr "接受條款及隱私政策"
msgstr ""
#: .\cookbook\helper\AllAuthCustomAdapter.py:30
msgid ""
"In order to prevent spam, the requested email was not send. Please wait a "
"few minutes and try again."
msgstr "為了防止垃圾郵件,所要求的電子郵件沒有被發送。請等待幾分鐘後再試。"
msgstr ""
#: .\cookbook\helper\permission_helper.py:124
#: .\cookbook\helper\permission_helper.py:144 .\cookbook\views\views.py:147
msgid "You are not logged in and therefore cannot view this page!"
msgstr "你还沒有登錄,因此不能查看這個頁面!"
msgstr ""
#: .\cookbook\helper\permission_helper.py:127
#: .\cookbook\helper\permission_helper.py:132
@@ -240,18 +234,18 @@ msgstr "你还沒有登錄,因此不能查看這個頁面!"
#: .\cookbook\views\views.py:158 .\cookbook\views\views.py:165
#: .\cookbook\views\views.py:253
msgid "You do not have the required permissions to view this page!"
msgstr "你沒有必要的權限來查看這個頁面!"
msgstr ""
#: .\cookbook\helper\permission_helper.py:148
#: .\cookbook\helper\permission_helper.py:170
#: .\cookbook\helper\permission_helper.py:185
msgid "You cannot interact with this object as it is not owned by you!"
msgstr "你不能與此對象交互,因為它不屬於你!"
msgstr ""
#: .\cookbook\helper\template_helper.py:60
#: .\cookbook\helper\template_helper.py:62
msgid "Could not parse template code."
msgstr "無法解析模板代碼。"
msgstr ""
#: .\cookbook\integration\integration.py:102
#: .\cookbook\templates\import.html:14 .\cookbook\templates\import.html:20
@@ -264,40 +258,40 @@ msgstr "無法解析模板代碼。"
#: .\cookbook\templates\url_import.html:604 .\cookbook\views\delete.py:60
#: .\cookbook\views\edit.py:199
msgid "Import"
msgstr "導入"
msgstr ""
#: .\cookbook\integration\integration.py:162
msgid ""
"Importer expected a .zip file. Did you choose the correct importer type for "
"your data ?"
msgstr "導入需要一個 .zip 文件。你是否為數據選擇了正確的導入器類型?"
msgstr ""
#: .\cookbook\integration\integration.py:165
msgid ""
"An unexpected error occurred during the import. Please make sure you have "
"uploaded a valid file."
msgstr "在導入過程中發生了一個意外的錯誤。請確認你上傳的文件是否有效。"
msgstr ""
#: .\cookbook\integration\integration.py:169
msgid "The following recipes were ignored because they already existed:"
msgstr "以下菜譜被忽略了,因為它們已經存在了:"
msgstr ""
#: .\cookbook\integration\integration.py:173
#, python-format
msgid "Imported %s recipes."
msgstr "導入了%s菜譜。"
msgstr ""
#: .\cookbook\integration\paprika.py:46
msgid "Notes"
msgstr "說明"
msgstr ""
#: .\cookbook\integration\paprika.py:49
msgid "Nutritional Information"
msgstr "營養信息"
msgstr ""
#: .\cookbook\integration\paprika.py:53
msgid "Source"
msgstr "來源"
msgstr ""
#: .\cookbook\integration\safron.py:23
#: .\cookbook\templates\forms\edit_internal_recipe.html:79
@@ -305,101 +299,101 @@ msgstr "來源"
#: .\cookbook\templates\url_import.html:224
#: .\cookbook\templates\url_import.html:455
msgid "Servings"
msgstr "份量"
msgstr ""
#: .\cookbook\integration\safron.py:25
msgid "Waiting time"
msgstr "等待時間"
msgstr ""
#: .\cookbook\integration\safron.py:27
#: .\cookbook\templates\forms\edit_internal_recipe.html:73
msgid "Preparation Time"
msgstr "準備時間"
msgstr ""
#: .\cookbook\integration\safron.py:29 .\cookbook\templates\base.html:78
#: .\cookbook\templates\forms\ingredients.html:7
#: .\cookbook\templates\index.html:7
msgid "Cookbook"
msgstr "菜譜"
msgstr ""
#: .\cookbook\integration\safron.py:31
msgid "Section"
msgstr "部分"
msgstr ""
#: .\cookbook\migrations\0047_auto_20200602_1133.py:14
msgid "Breakfast"
msgstr "早餐"
msgstr ""
#: .\cookbook\migrations\0047_auto_20200602_1133.py:19
msgid "Lunch"
msgstr "午餐"
msgstr ""
#: .\cookbook\migrations\0047_auto_20200602_1133.py:24
msgid "Dinner"
msgstr "晚餐"
msgstr ""
#: .\cookbook\migrations\0047_auto_20200602_1133.py:29
msgid "Other"
msgstr "其他"
msgstr ""
#: .\cookbook\models.py:71
msgid ""
"Maximum file storage for space in MB. 0 for unlimited, -1 to disable file "
"upload."
msgstr "空間的最大文件存儲量,單位為 MB。0表示無限製-1表示禁止上傳文件。"
msgstr ""
#: .\cookbook\models.py:121 .\cookbook\templates\search.html:7
#: .\cookbook\templates\shopping_list.html:52
msgid "Search"
msgstr "搜索"
msgstr ""
#: .\cookbook\models.py:122 .\cookbook\templates\base.html:92
#: .\cookbook\templates\meal_plan.html:5 .\cookbook\views\delete.py:152
#: .\cookbook\views\edit.py:233 .\cookbook\views\new.py:201
msgid "Meal-Plan"
msgstr "膳食計劃"
msgstr ""
#: .\cookbook\models.py:123 .\cookbook\templates\base.html:89
msgid "Books"
msgstr "書籍"
msgstr ""
#: .\cookbook\models.py:131
msgid "Small"
msgstr ""
msgstr ""
#: .\cookbook\models.py:131
msgid "Large"
msgstr ""
msgstr ""
#: .\cookbook\models.py:131 .\cookbook\templates\generic\new_template.html:6
#: .\cookbook\templates\generic\new_template.html:14
#: .\cookbook\templates\meal_plan.html:323
msgid "New"
msgstr ""
msgstr ""
#: .\cookbook\models.py:340
#: .\cookbook\templates\forms\edit_internal_recipe.html:202
msgid "Text"
msgstr "文本"
msgstr ""
#: .\cookbook\models.py:340
#: .\cookbook\templates\forms\edit_internal_recipe.html:203
msgid "Time"
msgstr "時間"
msgstr ""
#: .\cookbook\models.py:340
#: .\cookbook\templates\forms\edit_internal_recipe.html:204
#: .\cookbook\templates\forms\edit_internal_recipe.html:218
msgid "File"
msgstr "文件"
msgstr ""
#: .\cookbook\serializer.py:109
msgid "File uploads are not enabled for this Space."
msgstr "未為此空間啟用文件上傳。"
msgstr ""
#: .\cookbook\serializer.py:117
msgid "You have reached your file upload limit."
msgstr "你已達到文件上傳的限製。"
msgstr ""
#: .\cookbook\tables.py:35 .\cookbook\templates\books.html:36
#: .\cookbook\templates\generic\edit_template.html:6
@@ -409,7 +403,7 @@ msgstr "你已達到文件上傳的限製。"
#: .\cookbook\templates\shopping_list.html:33
#: .\cookbook\templates\space.html:84
msgid "Edit"
msgstr "編輯"
msgstr ""
#: .\cookbook\tables.py:124 .\cookbook\tables.py:147
#: .\cookbook\templates\books.html:38
@@ -419,28 +413,28 @@ msgstr "編輯"
#: .\cookbook\templates\meal_plan.html:277
#: .\cookbook\templates\recipes_table.html:90
msgid "Delete"
msgstr "刪除"
msgstr ""
#: .\cookbook\templates\404.html:5
msgid "404 Error"
msgstr "404錯誤"
msgstr ""
#: .\cookbook\templates\404.html:18
msgid "The page you are looking for could not be found."
msgstr "找不到你要找的頁面。"
msgstr ""
#: .\cookbook\templates\404.html:33
msgid "Take me Home"
msgstr "回到主頁"
msgstr ""
#: .\cookbook\templates\404.html:35
msgid "Report a Bug"
msgstr "報告一個錯誤"
msgstr ""
#: .\cookbook\templates\account\email.html:6
#: .\cookbook\templates\account\email.html:9
msgid "E-mail Addresses"
msgstr "電子郵件地址"
msgstr ""
#: .\cookbook\templates\account\email.html:11
msgid "The following e-mail addresses are associated with your account:"
@@ -1775,7 +1769,7 @@ msgstr ""
#: .\cookbook\templates\space.html:100
msgid "user"
msgstr "用戶"
msgstr ""
#: .\cookbook\templates\space.html:101
msgid "guest"

View File

@@ -0,0 +1,31 @@
# Generated by Django 4.0.7 on 2022-10-02 11:51
import cookbook.models
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0184_alter_userpreference_image'),
]
operations = [
migrations.CreateModel(
name='CookingMachine',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.CharField(choices=[('HOMECONNECT_COOKIT', 'HomeConnect CookIt')], max_length=128)),
('name', models.CharField(max_length=128)),
('serial', models.CharField(blank=True, max_length=512, null=True)),
('description', models.TextField(blank=True, default='')),
('access_token', models.CharField(blank=True, max_length=4096, null=True)),
('access_token_expiry', models.DateTimeField(blank=True, null=True)),
('refresh_token', models.CharField(blank=True, max_length=4096, null=True)),
('refresh_token_expiry', models.DateTimeField(blank=True, null=True)),
('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
],
bases=(models.Model, cookbook.models.PermissionModelMixin),
),
]

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