Merge branch 'develop' into docs/docker-installation

This commit is contained in:
MaxJa4 2022-01-23 01:38:04 +01:00 committed by GitHub
commit 075c88e5e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 2460 additions and 2041 deletions

View File

@ -1,4 +1,4 @@
name: Continous Integration
name: Continuous Integration
on: [push]

View File

@ -9,7 +9,7 @@
<h4 align="center">The recipe manager that allows you to manage your ever growing collection of digital recipes.</h4>
<p align="center">
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=master" ></a>
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continuous%20Integration/badge.svg?branch=master" ></a>
<a href="https://github.com/vabene1111/recipes/stargazers" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/stars/vabene1111/recipes" ></a>
<a href="https://github.com/vabene1111/recipes/network/members" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/forks/vabene1111/recipes" ></a>
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer"><img src="https://badgen.net/badge/icon/discord?icon=discord&label" ></a>

View File

@ -7,4 +7,4 @@ Since this software is still considered beta/WIP support is always only given fo
## Reporting a Vulnerability
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 communitcation there (since GitHub does not allow everyone to create a security advisory :/).
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

@ -442,7 +442,7 @@ class SearchPreferenceForm(forms.ModelForm):
help_texts = {
'search': _(
'Select type method of search. Click <a href="/docs/search/">here</a> for full desciption of choices.'),
'Select type method of search. Click <a href="/docs/search/">here</a> for full description of choices.'),
'lookup': _('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'),
'unaccent': _(
'Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'),
@ -461,7 +461,7 @@ class SearchPreferenceForm(forms.ModelForm):
'lookup': _('Fuzzy Lookups'),
'unaccent': _('Ignore Accent'),
'icontains': _("Partial Match"),
'istartswith': _("Starts Wtih"),
'istartswith': _("Starts With"),
'trigram': _("Fuzzy Search"),
'fulltext': _("Full Text")
}

View File

@ -34,7 +34,7 @@ def has_group_permission(user, groups):
"""
Tests if a given user is member of a certain group (or any higher group)
Superusers always bypass permission checks.
Unauthenticated users cant be member of any group thus always return false.
Unauthenticated users can't be member of any group thus always return false.
:param user: django auth user object
:param groups: list or tuple of groups the user should be checked for
:return: True if user is in allowed groups, false otherwise

View File

@ -126,7 +126,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
# if servings have changed, update the ShoppingListRecipe and existing Entrys
# if servings have changed, update the ShoppingListRecipe and existing Entries
if servings <= 0:
servings = 1
@ -138,7 +138,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
sle.save()
# add any missing Entrys
# add any missing Entries
for i in [x for x in add_ingredients if x.food]:
ShoppingListEntry.objects.create(

View File

@ -27,10 +27,10 @@ class RecetteTek(Integration):
def get_recipe_from_file(self, file):
# Create initial recipe with just a title and a decription
# Create initial recipe with just a title and a description
recipe = Recipe.objects.create(name=file['title'], created_by=self.request.user, internal=True, space=self.request.space, )
# set the description as an empty string for later use for the source URL, incase there is no description text.
# set the description as an empty string for later use for the source URL, in case there is no description text.
recipe.description = ''
try:

View File

@ -15,10 +15,10 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: 2021-11-12 20:06+0000\n"
"Last-Translator: A. L. <richard@anska.de>\n"
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/recipes-"
"backend/de/>\n"
"PO-Revision-Date: 2022-01-20 22:47+0000\n"
"Last-Translator: Sebastian Weber <tandoor@web3r.de>\n"
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/de/>\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@ -122,10 +122,8 @@ msgstr ""
"sollen."
#: .\cookbook\forms.py:80
#, fuzzy
#| msgid "Try the new shopping list"
msgid "Users with whom to share shopping lists."
msgstr "Neue Einkaufsliste ausprobieren"
msgstr "Benutzer, mit denen Einkaufslisten geteilt werden sollen."
#: .\cookbook\forms.py:82
msgid "Show recently viewed recipes on search page."

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
if settings.DATABASES['default']['ENGINE'] not in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
self.stdout.write(self.style.WARNING(_('Only Postgress databases use full text search, no index to rebuild')))
self.stdout.write(self.style.WARNING(_('Only Postgresql databases use full text search, no index to rebuild')))
try:
language = DICTIONARY.get(translation.get_language(), 'simple')

View File

@ -0,0 +1,26 @@
# Generated by Django 3.2.11 on 2022-01-20 14:39
from django.db import migrations, models
from django_scopes import scopes_disabled
def add_default_trigram(apps, schema_editor):
with scopes_disabled():
UserPreference = apps.get_model('cookbook', 'UserPreference')
UserPreference.objects.all().update(shopping_add_onhand=False)
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0165_remove_step_type'),
]
operations = [
migrations.AlterField(
model_name='userpreference',
name='shopping_add_onhand',
field=models.BooleanField(default=False),
),
migrations.RunPython(add_default_trigram),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.11 on 2022-01-20 22:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0166_alter_userpreference_shopping_add_onhand'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='left_handed',
field=models.BooleanField(default=False),
),
]

View File

@ -337,8 +337,9 @@ class UserPreference(models.Model, PermissionModelMixin):
mealplan_autoadd_shopping = models.BooleanField(default=False)
mealplan_autoexclude_onhand = models.BooleanField(default=True)
mealplan_autoinclude_related = models.BooleanField(default=True)
shopping_add_onhand = models.BooleanField(default=True)
shopping_add_onhand = models.BooleanField(default=False)
filter_to_supermarket = models.BooleanField(default=False)
left_handed = models.BooleanField(default=False)
default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4)
shopping_recent_days = models.PositiveIntegerField(default=7)
csv_delim = models.CharField(max_length=2, default=",")
@ -515,7 +516,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
@staticmethod
def reset_inheritance(space=None):
# resets inheritted fields to the space defaults and updates all inheritted fields to root object values
# resets inherited fields to the space defaults and updates all inherited fields to root object values
inherit = space.food_inherit.all()
# remove all inherited fields from food

View File

@ -33,7 +33,7 @@ class Nextcloud(Provider):
try:
files.pop(0) # remove first element because its the folder itself
except IndexError:
pass # folder is emtpy, no recipes will be imported
pass # folder is empty, no recipes will be imported
import_count = 0
for file in files:

View File

@ -183,7 +183,7 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_kj', 'search_style', 'show_recent', 'plan_share',
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix',
'filter_to_supermarket', 'shopping_add_onhand'
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed'
)
@ -865,7 +865,7 @@ class AutomationSerializer(serializers.ModelSerializer):
# CORS, REST and Scopes aren't currently working
# Scopes are evaluating before REST has authenticated the user assiging a None space
# Scopes are evaluating before REST has authenticated the user assigning a None space
# I've made the change below to fix the bookmarklet, other serializers likely need a similar/better fix
class BookmarkletImportSerializer(serializers.ModelSerializer):
def create(self, validated_data):

View File

@ -72,7 +72,7 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs):
return
inherit = inherit.values_list('field', flat=True)
# apply changes from parent to instance for each inheritted field
# apply changes from parent to instance for each inherited field
if instance.parent and inherit.count() > 0:
parent = instance.get_parent()
if 'ignore_shopping' in inherit:

View File

@ -10461,4 +10461,9 @@ textarea, input:not([type="submit"]):not([class="multiselect__input"]):not([clas
.form-control-search {
font-size: 20px;
}
.ghost {
opacity: 0.5 !important;
background: #b98766 !important;
}

View File

@ -54,7 +54,7 @@
<h2>{% trans 'Formatting' %}</h2>
<pre class="intro-code code-block"><code>
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}
{% trans 'or by leaving a blank line inbetween.' %}
{% trans 'or by leaving a blank line in between.' %}
**{% trans 'This text is bold' %}**
*{% trans 'This text is italic' %}*
@ -70,7 +70,7 @@
<div class="card">
<div class="card-body">
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}<br/>
{% trans 'or by leaving a blank line inbetween.' %}<br/><br/>
{% trans 'or by leaving a blank line in between.' %}<br/><br/>
<b>{% trans 'This text is bold' %}</b><br/>
<i>{% trans 'This text is italic' %}</i>
<blockquote>
@ -82,7 +82,7 @@
<br/>
<h2>{% trans 'Lists' %}</h2>
{% trans 'Lists can ordered or unorderd. It is <b>important to leave a blank line before the list!</b>' %}
{% trans 'Lists can ordered or unordered. It is <b>important to leave a blank line before the list!</b>' %}
<pre class="intro-code code-block"><code>
{% trans 'Ordered List' %}

View File

@ -27,7 +27,7 @@
{% endblocktrans %}</p>
<h4>{% trans 'Simple' %}</h4>
<p> {% blocktrans %}
Simple searches ignore punctuation and common words such as 'the', 'a', 'and'. And will treat seperate words as required.
Simple searches ignore punctuation and common words such as 'the', 'a', 'and'. And will treat separate words as required.
Searching for 'apple or flour' will return any recipe that includes both 'apple' and 'flour' anywhere in the fields that have been selected for a full text search.
{% endblocktrans %}</p>
<h4>{% trans 'Phrase' %}</h4>
@ -39,7 +39,7 @@
<p> {% blocktrans %}
Web searches simulate functionality found on many web search sites supporting special syntax.
Placing quotes around several words will convert those words into a phrase.
'or' is recongized as searching for the word (or phrase) immediately before 'or' OR the word (or phrase) directly after.
'or' is recognized as searching for the word (or phrase) immediately before 'or' OR the word (or phrase) directly after.
'-' is recognized as searching for recipes that do not include the word (or phrase) that comes immediately after.
For example searching for 'apple pie' or cherry -butter will return any recipe that includes the phrase 'apple pie' or the word 'cherry'
in any field included in the full text search but exclude any recipe that has the word 'butter' in any field included.
@ -59,7 +59,7 @@
{% blocktrans %}
Another approach to searching that also requires Postgresql is fuzzy search or trigram similarity. A trigram is a group of three consecutive characters.
For example searching for 'apple' will create x trigrams 'app', 'ppl', 'ple' and will create a score of how closely words match the generated trigrams.
One benefit of searching trigams is that a search for 'sandwich' will find mispelled words such as 'sandwhich' that would be missed by other methods.
One benefit of searching trigams is that a search for 'sandwich' will find misspelled words such as 'sandwhich' that would be missed by other methods.
{% endblocktrans %}
</div>

View File

@ -263,7 +263,7 @@
{% if request.user.userpreference.shopping_auto_sync > 0 %}
<div class="row" v-if="!onLine">
<div class="col col-md-12">
<div class="alert alert-warning" role="alert">{% trans 'You are offline, shopping list might not syncronize.' %}</div>
<div class="alert alert-warning" role="alert">{% trans 'You are offline, shopping list might not synchronize.' %}</div>
</div>
</div>
{% endif %}
@ -634,7 +634,7 @@
console.log('updating recipe', this.shopping_list.recipes[i])
recipe_promises.push(this.$http.post("{% url 'api:shoppinglistrecipe-list' %}", this.shopping_list.recipes[i], {}).then((response) => {
let old_id = this.shopping_list.recipes[i].id
console.log("list recipe create respose ", response.body)
console.log("list recipe create response ", response.body)
this.$set(this.shopping_list.recipes, i, response.body)
for (let e of this.shopping_list.entries.filter(item => item.list_recipe === old_id)) {
console.log("found recipe updating ID")

View File

@ -908,28 +908,22 @@
// })
},
searchUnits: function (query) {
let apiFactory = new ApiApiFactory()
this.units_loading = true
apiFactory
.listUnits(query, 1, this.options_limit)
.then((response) => {
this.units = response.data.results
if (this.recipe !== undefined) {
for (let s of this.recipe.steps) {
for (let i of s.ingredients) {
if (i.unit !== null && i.unit.id === undefined) {
this.units.push(i.unit)
}
}
this.$http.get("{% url 'dal_unit' %}" + '?q=' + query).then((response) => {
this.units = response.data.results;
if (this.recipe_data !== undefined) {
for (let x of Array.from(this.recipe_data.recipeIngredient)) {
if (x.unit !== null && x.unit.text !== '') {
this.units = this.units.filter(item => item.text !== x.unit.text)
this.units.push(x.unit)
}
}
this.units_loading = false
})
.catch((err) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
})
}
this.units_loading = false
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
},
searchIngredients: function (query) {
this.ingredients_loading = true

View File

@ -1088,7 +1088,7 @@ def recipe_from_source(request):
return JsonResponse(
{
'error': True,
'msg': _('No useable data could be found.')
'msg': _('No usable data could be found.')
},
status=400
)

View File

@ -411,7 +411,7 @@ def user_settings(request):
if (api_token := Token.objects.filter(user=request.user).first()) is None:
api_token = Token.objects.create(user=request.user)
# these fields require postgress - just disable them if postgress isn't available
# these fields require postgresql - just disable them if postgresql isn't available
if not settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
'django.db.backends.postgresql']:
search_form.fields['search'].disabled = True

View File

@ -51,7 +51,7 @@ In technical terms it is a multi tenant system.
You can compare a space to something like google drive or dropbox.
There is only one installation of the Dropbox system, but it handles multiple users without them noticing each other.
For Tandoor that means all people that work together on one recipe collection can be in one space.
If you want to host the collection of your friends family or your neighbor you can create a separate space for them (trough the admin interface).
If you want to host the collection of your friends family or your neighbor you can create a separate space for them (through the admin interface).
Sharing between spaces is currently not possible but is planned for future releases.

View File

@ -25,8 +25,8 @@ SOCIAL_PROVIDERS=allauth.socialaccount.providers.github,allauth.socialaccount.pr
The exact formatting is important so make sure to follow the steps explained here!
Depending on your authentication provider you **might need** to configure it.
This needs to be done trough the settings system. To make the system flexible (allow multiple providers) and to
not require another file to be mounted into the container the configuration ins done trough a single
This needs to be done through the settings system. To make the system flexible (allow multiple providers) and to
not require another file to be mounted into the container the configuration ins done through a single
environment variable. The downside of this approach is that the configuration needs to be put into a single line
as environment files loaded by docker compose don't support multiple lines for a single variable.

View File

@ -35,7 +35,7 @@ The basic configuration is the same for all providers.
### Local
!!! info
There is currently no way to upload files trough the webinterface. This is a feature that might be added later.
There is currently no way to upload files through the webinterface. This is a feature that might be added later.
The local provider does not need any configuration.
For the monitor you will need to define a valid path on your host system.

View File

@ -9,7 +9,7 @@
<h4 align="center">The recipe manager that allows you to manage your ever growing collection of digital recipes.</h4>
<p align="center">
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=master" ></a>
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continuous%20Integration/badge.svg?branch=master" ></a>
<a href="https://github.com/vabene1111/recipes/stargazers" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/stars/vabene1111/recipes" ></a>
<a href="https://github.com/vabene1111/recipes/network/members" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/forks/vabene1111/recipes" ></a>
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer"><img src="https://badgen.net/badge/icon/discord?icon=discord&label" ></a>
@ -85,7 +85,7 @@ there are some greater overall goals for the future (in no particular order)
- Improve the UI! The Design is inconsistent and many pages work but don't look great. This needs to change.
- I strongly believe in Open Data and Systems. Thus adding importers and exporters for all relevant other recipe management systems is something i really want to do.
- Move all Javascript Libraries to a packet manger and clean up some of the mess I made in the early days
- Move all Javascript Libraries to a packet manager and clean up some of the mess I made in the early days
- Improve Test coverage and also the individual tests themselves
- Improve the documentation for all features and aspects of this project and add some application integrated help

View File

@ -3,7 +3,7 @@
## K8s Setup
This is a setup which should be sufficent for production use. Be sure to replace the default secrets!
This is a setup which should be sufficient for production use. Be sure to replace the default secrets!
## Files
@ -49,7 +49,7 @@ The creation of the persistent volume claims for media and static content. May y
### 40-sts-postgresql.yaml
The PostgreSQL stateful set, based on a bitnami image. It runs a init container as root to do the preparations. The postgres container itsef runs as a lower privileged user. The recipes app uses the database super user (postgres) as the recipies app is doing some db migrations on startup, which needs super user privileges.
The PostgreSQL stateful set, based on a bitnami image. It runs a init container as root to do the preparations. The postgres container itself runs as a lower privileged user. The recipes app uses the database super user (postgres) as the recipes app is doing some db migrations on startup, which needs super user privileges.
### 45-service-db.yaml
@ -57,7 +57,7 @@ Creating the database service.
### 50-deployment.yaml
The deployment first fires up a init container to do the database migrations and file modifications. This init container runs as root. The init conainer runs part of the [boot.sh](https://github.com/TandoorRecipes/recipes/blob/develop/boot.sh) script from the `vabene1111/recipes` image.
The deployment first fires up a init container to do the database migrations and file modifications. This init container runs as root. The init container runs part of the [boot.sh](https://github.com/TandoorRecipes/recipes/blob/develop/boot.sh) script from the `vabene1111/recipes` image.
The deployment then runs two containers, the recipes-nginx and the recipes container which runs the gunicorn app. The nginx container gets it's nginx.conf via config map to deliver static content `/static` and `/media`. The guincorn container gets it's secret key and the database password from the secret `recipes`. `gunicorn` runs as user `nobody`.

View File

@ -1,6 +1,6 @@
# Manual installation instructions
These intructions are inspired from a standard django/gunicorn/postgresql instructions ([for example](https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu-16-04))
These instructions are inspired from a standard django/gunicorn/postgresql instructions ([for example](https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu-16-04))
!!! warning
Be sure to use python 3.9 and pip related to python 3.9. Depending on your distribution calling `python` or `pip` will use python2 instead of python 3.9.
@ -122,7 +122,7 @@ ExecStart=/var/www/recipes/bin/gunicorn --error-logfile /tmp/gunicorn_err.log --
WantedBy=multi-user.target
```
*Note*: `-error-logfile /tmp/gunicorn_err.log --log-level debug --capture-output` are usefull for debugging and can be removed later
*Note*: `-error-logfile /tmp/gunicorn_err.log --log-level debug --capture-output` are useful for debugging and can be removed later
*Note2*: Fix the path in the `ExecStart` line to where you gunicorn and recipes are

View File

@ -57,4 +57,4 @@ apache:
I used two paths `<sub path>` and `<www path>` for simplicity. In my case I have `<sub path> = recipes` and `<www path> = serve/recipes`. One could also change the matching rules of traefik to have everything under one path.
I left out the TLS config in this example for simplicty.
I left out the TLS config in this example for simplicity.

View File

@ -80,7 +80,7 @@ Easiest way is to do it via Reverse Proxy.
- insert name
- Source:
- Protocol: HTTPS
- Hostname: URL if you acces from outside, otherwise ip in network
- Hostname: URL if you access from outside, otherwise ip in network
- Port: The port you want to access, has to be a different one that the one in the docker-compose file
- HSTS can be enabled
- Destination:
@ -90,14 +90,14 @@ Easiest way is to do it via Reverse Proxy.
- Click on Custom Header and press Create -> Websocket
- Save
- Control Panel -> Security -> Firewall -> Edit Rules -> Create
- Ports: Select form a list of build-in applications -> Select -> You find your Reverse Proxy, enable it
- Ports: Select form a list of built-in applications -> Select -> You find your Reverse Proxy, enable it
- Source IP: Depends, All allows access from outside, i use specific to only connect in my network
- Action: Allow
- Save and make sure it's above the deny rules
[Deprecated, Note: ssl Path changed for DSM 7]
6.1 Additional SSL Setup
- create foler `ssl` inside `nginx` folder
- create folder `ssl` inside `nginx` folder
- download your ssl certificate from `security` tab in dsm `control panel`
- or create a task in `task manager` because Synology will update the certificate every few months
- set task to repeat every day

View File

@ -1,6 +1,6 @@
!!! danger
Please refer to [the offical documentation](https://doc.traefik.io/traefik/).
This example just shows something similar to my setup in case you dont understand the offical documentation.
Please refer to [the official documentation](https://doc.traefik.io/traefik/).
This example just shows something similar to my setup in case you dont understand the official documentation.
You need to create a network called `traefik` using `docker network create traefik`.
## docker-compose.yml

View File

@ -2,7 +2,7 @@ There is currently no "good" way of backing up your data implemented in the appl
This mean that you will be responsible for backing up your data.
It is planned to add a "real" backup feature similar to applications like homeassistant where a snapshot can be
downloaded and restored trough the web interface.
downloaded and restored through the web interface.
!!! warning
When developing a new backup strategy, make sure to also test the restore process!

View File

@ -21,13 +21,13 @@ The following table roughly defines the capabilities of each role
!!! warning
Users without groups cannot do anything. Make sure to assign them a group!
You can either create new users trough the admin interface or by sending them invite links.
You can either create new users through the admin interface or by sending them invite links.
Invite links can be generated on the System page. If you specify a username during the creation of the link
the person using it won't be able to change that name.
## Managing Permissions
Management of permissions can currently only be achieved trough the django admin interface.
Management of permissions can currently only be achieved through the django admin interface.
!!! warning
Please do not rename the groups as this breaks the permission system.

View File

@ -407,7 +407,7 @@ TEST_RUNNER = "cookbook.helper.CustomTestRunner.CustomTestRunner"
# settings for cross site origin (CORS)
# all origins allowed to support bookmarklet
# all of this may or may not work with nginx or other web servers
# TODO make this user configureable - enable or disable bookmarklets
# TODO make this user configurable - enable or disable bookmarklets
# TODO since token auth is enabled - this all should be https by default
CORS_ORIGIN_ALLOW_ALL = True

View File

@ -79,21 +79,20 @@
<div class="col-12 col-md-9 col-lg-6">
<h5>{{ $t("Meal_Types") }}</h5>
<div>
<draggable :list="meal_types" group="meal_types" :empty-insert-threshold="10"
handle=".handle" @sort="sortMealTypes()">
<b-card no-body class="mt-1" v-for="(meal_type, index) in meal_types" v-hover
<draggable :list="meal_types" group="meal_types" :empty-insert-threshold="10" @sort="sortMealTypes()" ghost-class="ghost">
<b-card no-body class="mt-1 list-group-item p-2" style="cursor:move" v-for="(meal_type, index) in meal_types" v-hover
:key="meal_type.id">
<b-card-header class="p-4">
<b-card-header class="p-2 border-0">
<div class="row">
<div class="col-2 handle">
<div class="col-2">
<button type="button" class="btn btn-lg shadow-none"><i
class="fas fa-arrows-alt-v"></i></button>
</div>
<div class="col-10">
<h5>
<h5 class="mt-1 mb-1">
{{ meal_type.icon }} {{
meal_type.name
}}<span class="float-right text-primary"
}}<span class="float-right text-primary" style="cursor:pointer"
><i class="fa"
v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }"
@click="editOrSaveMealType(index)" aria-hidden="true"></i
@ -878,4 +877,9 @@ having to override as much.
.theme-default .cv-day.draghover {
box-shadow: inset 0 0 0.2em 0.2em yellow;
}
.ghost {
opacity: 0.5;
background: #c8ebfb;
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<div id="app" style="margin-bottom: 4vh">
<RecipeSwitcher ref="ref_recipe_switcher" />
<RecipeSwitcher ref="ref_recipe_switcher"/>
<div class="row">
<div class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1">
<div class="row">
@ -8,15 +8,21 @@
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8 mt-3 mb-3">
<b-input-group>
<b-input class="form-control form-control-lg form-control-borderless form-control-search" v-model="search.search_input" v-bind:placeholder="$t('Search')"></b-input>
<b-input
class="form-control form-control-lg form-control-borderless form-control-search"
v-model="search.search_input" v-bind:placeholder="$t('Search')"></b-input>
<b-input-group-append>
<b-button v-b-tooltip.hover :title="$t('show_sql')" @click="showSQL()" v-if="debug && ui.sql_debug">
<b-button v-b-tooltip.hover :title="$t('show_sql')" @click="showSQL()"
v-if="debug && ui.sql_debug">
<i class="fas fa-bug" style="font-size: 1.5em"></i>
</b-button>
<b-button variant="light" v-b-tooltip.hover :title="$t('Random Recipes')" @click="openRandom()">
<b-button variant="light" v-b-tooltip.hover :title="$t('Random Recipes')"
@click="openRandom()">
<i class="fas fa-dice-five" style="font-size: 1.5em"></i>
</b-button>
<b-button v-b-toggle.collapse_advanced_search v-b-tooltip.hover :title="$t('Advanced Settings')" v-bind:variant="!searchFiltered(true) ? 'primary' : 'danger'">
<b-button v-b-toggle.collapse_advanced_search v-b-tooltip.hover
:title="$t('Advanced Settings')"
v-bind:variant="!searchFiltered(true) ? 'primary' : 'danger'">
<!-- TODO consider changing this icon to a filter -->
<i class="fas fa-caret-down" v-if="!search.advanced_search_visible"></i>
<i class="fas fa-caret-up" v-if="search.advanced_search_visible"></i>
@ -26,15 +32,18 @@
</div>
</div>
<b-collapse id="collapse_advanced_search" class="mt-2 shadow-sm" v-model="search.advanced_search_visible">
<b-collapse id="collapse_advanced_search" class="mt-2 shadow-sm"
v-model="search.advanced_search_visible">
<div class="card">
<div class="card-body p-4">
<div class="row">
<div class="col-md-3">
<a class="btn btn-primary btn-block text-uppercase" :href="resolveDjangoUrl('new_recipe')">{{ $t("New_Recipe") }}</a>
<a class="btn btn-primary btn-block text-uppercase"
:href="resolveDjangoUrl('new_recipe')">{{ $t("New_Recipe") }}</a>
</div>
<div class="col-md-3">
<a class="btn btn-primary btn-block text-uppercase" :href="resolveDjangoUrl('data_import_url')">{{ $t("Import") }}</a>
<a class="btn btn-primary btn-block text-uppercase"
:href="resolveDjangoUrl('data_import_url')">{{ $t("Import") }}</a>
</div>
<div class="col-md-3">
<button
@ -53,57 +62,92 @@
</div>
<div class="col-md-3">
<button id="id_settings_button" class="btn btn-primary btn-block text-uppercase"><i class="fas fa-cog fa-lg m-1"></i></button>
<button id="id_settings_button"
class="btn btn-primary btn-block text-uppercase"><i
class="fas fa-cog fa-lg m-1"></i></button>
</div>
</div>
<b-popover target="id_settings_button" triggers="click" placement="bottom">
<b-tabs content-class="mt-1" small>
<b-tab :title="$t('Settings')" active>
<b-form-group v-bind:label="$t('Recently_Viewed')" label-for="popover-input-1" label-cols="6" class="mb-3">
<b-form-input type="number" v-model="ui.recently_viewed" id="popover-input-1" size="sm"></b-form-input>
<b-form-group v-bind:label="$t('Recently_Viewed')"
label-for="popover-input-1" label-cols="6" class="mb-3">
<b-form-input type="number" v-model="ui.recently_viewed"
id="popover-input-1" size="sm"></b-form-input>
</b-form-group>
<b-form-group v-bind:label="$t('Recipes_per_page')" label-for="popover-input-page-count" label-cols="6" class="mb-3">
<b-form-input type="number" v-model="ui.page_size" id="popover-input-page-count" size="sm"></b-form-input>
<b-form-group v-bind:label="$t('Recipes_per_page')"
label-for="popover-input-page-count" label-cols="6"
class="mb-3">
<b-form-input type="number" v-model="ui.page_size"
id="popover-input-page-count"
size="sm"></b-form-input>
</b-form-group>
<b-form-group v-bind:label="$t('Meal_Plan')" label-for="popover-input-2" label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.show_meal_plan" id="popover-input-2" size="sm"></b-form-checkbox>
<b-form-group v-bind:label="$t('Meal_Plan')" label-for="popover-input-2"
label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.show_meal_plan"
id="popover-input-2" size="sm"></b-form-checkbox>
</b-form-group>
<b-form-group v-if="ui.show_meal_plan" v-bind:label="$t('Meal_Plan_Days')" label-for="popover-input-5" label-cols="6" class="mb-3">
<b-form-input type="number" v-model="ui.meal_plan_days" id="popover-input-5" size="sm"></b-form-input>
<b-form-group v-if="ui.show_meal_plan"
v-bind:label="$t('Meal_Plan_Days')"
label-for="popover-input-5" label-cols="6" class="mb-3">
<b-form-input type="number" v-model="ui.meal_plan_days"
id="popover-input-5" size="sm"></b-form-input>
</b-form-group>
<b-form-group v-bind:label="$t('Sort_by_new')" label-for="popover-input-3" label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.sort_by_new" id="popover-input-3" size="sm"></b-form-checkbox>
<b-form-group v-bind:label="$t('Sort_by_new')"
label-for="popover-input-3" label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.sort_by_new"
id="popover-input-3" size="sm"></b-form-checkbox>
</b-form-group>
<div class="row" style="margin-top: 1vh">
<div class="col-12">
<a :href="resolveDjangoUrl('view_settings') + '#search'">{{ $t("Search Settings") }}</a>
<a :href="resolveDjangoUrl('view_settings') + '#search'">{{
$t("Search Settings")
}}</a>
</div>
</div>
</b-tab>
<b-tab title="Expert Settings">
<b-form-group v-bind:label="$t('remember_search')" label-for="popover-rem-search" label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.remember_search" id="popover-rem-search" size="sm"></b-form-checkbox>
<b-form-group v-bind:label="$t('remember_search')"
label-for="popover-rem-search" label-cols="6"
class="mb-3">
<b-form-checkbox switch v-model="ui.remember_search"
id="popover-rem-search"
size="sm"></b-form-checkbox>
</b-form-group>
<b-form-group v-if="ui.remember_search" v-bind:label="$t('remember_hours')" label-for="popover-input-rem-hours" label-cols="6" class="mb-3">
<b-form-input type="number" v-model="ui.remember_hours" id="popover-rem-hours" size="sm"></b-form-input>
<b-form-group v-if="ui.remember_search"
v-bind:label="$t('remember_hours')"
label-for="popover-input-rem-hours" label-cols="6"
class="mb-3">
<b-form-input type="number" v-model="ui.remember_hours"
id="popover-rem-hours" size="sm"></b-form-input>
</b-form-group>
<b-form-group v-bind:label="$t('tree_select')" label-for="popover-input-treeselect" label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.tree_select" id="popover-input-treeselect" size="sm"></b-form-checkbox>
<b-form-group v-bind:label="$t('tree_select')"
label-for="popover-input-treeselect" label-cols="6"
class="mb-3">
<b-form-checkbox switch v-model="ui.tree_select"
id="popover-input-treeselect"
size="sm"></b-form-checkbox>
</b-form-group>
<b-form-group v-if="debug" v-bind:label="$t('sql_debug')" label-for="popover-input-sqldebug" label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.sql_debug" id="popover-input-sqldebug" size="sm"></b-form-checkbox>
<b-form-group v-if="debug" v-bind:label="$t('sql_debug')"
label-for="popover-input-sqldebug" label-cols="6"
class="mb-3">
<b-form-checkbox switch v-model="ui.sql_debug"
id="popover-input-sqldebug"
size="sm"></b-form-checkbox>
</b-form-group>
</b-tab>
</b-tabs>
<div class="row" style="margin-top: 1vh">
<div class="col-12" style="text-align: right">
<b-button size="sm" variant="secondary" style="margin-right: 8px" @click="$root.$emit('bv::hide::popover')">{{ $t("Close") }} </b-button>
<b-button size="sm" variant="secondary" style="margin-right: 8px"
@click="$root.$emit('bv::hide::popover')">{{ $t("Close") }}
</b-button>
</div>
</div>
</b-popover>
@ -138,8 +182,12 @@
></generic-multiselect>
<b-input-group-append>
<b-input-group-text>
<b-form-checkbox v-model="search.search_keywords_or" name="check-button" @change="refreshData(false)" class="shadow-none" switch>
<span class="text-uppercase" v-if="search.search_keywords_or">{{ $t("or") }}</span>
<b-form-checkbox v-model="search.search_keywords_or"
name="check-button"
@change="refreshData(false)"
class="shadow-none" switch>
<span class="text-uppercase"
v-if="search.search_keywords_or">{{ $t("or") }}</span>
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
</b-form-checkbox>
</b-input-group-text>
@ -178,8 +226,13 @@
></generic-multiselect>
<b-input-group-append>
<b-input-group-text>
<b-form-checkbox v-model="search.search_foods_or" name="check-button" @change="refreshData(false)" class="shadow-none" switch>
<span class="text-uppercase" v-if="search.search_foods_or">{{ $t("or") }}</span>
<b-form-checkbox v-model="search.search_foods_or"
name="check-button"
@change="refreshData(false)"
class="shadow-none" switch>
<span class="text-uppercase" v-if="search.search_foods_or">{{
$t("or")
}}</span>
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
</b-form-checkbox>
</b-input-group-text>
@ -203,8 +256,13 @@
></generic-multiselect>
<b-input-group-append>
<b-input-group-text>
<b-form-checkbox v-model="search.search_books_or" name="check-button" @change="refreshData(false)" class="shadow-none" tyle="width: 100%" switch>
<span class="text-uppercase" v-if="search.search_books_or">{{ $t("or") }}</span>
<b-form-checkbox v-model="search.search_books_or"
name="check-button"
@change="refreshData(false)"
class="shadow-none" tyle="width: 100%" switch>
<span class="text-uppercase" v-if="search.search_books_or">{{
$t("or")
}}</span>
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
</b-form-checkbox>
</b-input-group-text>
@ -227,7 +285,7 @@
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
/>
<b-input-group-append>
<b-input-group-text style="width: 85px"> </b-input-group-text>
<b-input-group-text style="width: 85px"></b-input-group-text>
</b-input-group-append>
</b-input-group>
</div>
@ -241,7 +299,9 @@
<div class="row">
<div class="col col-md-12 text-right" style="margin-top: 2vh">
<span class="text-muted">
{{ $t("Page") }} {{ search.pagination_page }}/{{ Math.ceil(pagination_count / ui.page_size) }}
{{ $t("Page") }} {{ search.pagination_page }}/{{
Math.ceil(pagination_count / ui.page_size)
}}
<a href="#" @click="resetSearch"><i class="fas fa-times-circle"></i> {{ $t("Reset") }}</a>
</span>
</div>
@ -249,18 +309,24 @@
<div class="row">
<div class="col col-md-12">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 0.8rem">
<div
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 0.8rem">
<template v-if="!searchFiltered()">
<recipe-card v-bind:key="`mp_${m.id}`" v-for="m in meal_plans" :recipe="m.recipe" :meal_plan="m" :footer_text="m.meal_type_name" footer_icon="far fa-calendar-alt"></recipe-card>
<recipe-card v-bind:key="`mp_${m.id}`" v-for="m in meal_plans" :recipe="m.recipe"
:meal_plan="m" :footer_text="m.meal_type_name"
footer_icon="far fa-calendar-alt"></recipe-card>
</template>
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r" :footer_text="isRecentOrNew(r)[0]" :footer_icon="isRecentOrNew(r)[1]"> </recipe-card>
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r"
:footer_text="isRecentOrNew(r)[0]"
:footer_icon="isRecentOrNew(r)[1]"></recipe-card>
</div>
</div>
</div>
<div class="row" style="margin-top: 2vh" v-if="!random_search">
<div class="col col-md-12">
<b-pagination pills v-model="search.pagination_page" :total-rows="pagination_count" :per-page="ui.page_size" @change="pageChange" align="center"> </b-pagination>
<b-pagination pills v-model="search.pagination_page" :total-rows="pagination_count"
:per-page="ui.page_size" @change="pageChange" align="center"></b-pagination>
</div>
</div>
</div>
@ -271,7 +337,7 @@
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import {BootstrapVue} from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import moment from "moment"
@ -281,13 +347,13 @@ import VueCookies from "vue-cookies"
Vue.use(VueCookies)
import { ApiMixin, ResolveUrlMixin } from "@/utils/utils"
import {ApiMixin, ResolveUrlMixin} from "@/utils/utils"
import LoadingSpinner from "@/components/LoadingSpinner" // TODO: is this deprecated?
import RecipeCard from "@/components/RecipeCard"
import GenericMultiselect from "@/components/GenericMultiselect"
import { Treeselect, LOAD_CHILDREN_OPTIONS } from "@riophae/vue-treeselect" //TODO: delete
import {Treeselect, LOAD_CHILDREN_OPTIONS} from "@riophae/vue-treeselect" //TODO: delete
import "@riophae/vue-treeselect/dist/vue-treeselect.css" //TODO: delete
import RecipeSwitcher from "@/components/Buttons/RecipeSwitcher"
@ -299,7 +365,7 @@ let UI_COOKIE_NAME = "_uisearch_settings"
export default {
name: "RecipeSearchView",
mixins: [ResolveUrlMixin, ApiMixin],
components: { GenericMultiselect, RecipeCard, Treeselect, RecipeSwitcher },
components: {GenericMultiselect, RecipeCard, Treeselect, RecipeSwitcher},
data() {
return {
// this.Models and this.Actions inherited from ApiMixin
@ -350,12 +416,12 @@ export default {
}
}
return [
{ id: 5, label: "⭐⭐⭐⭐⭐" + ratingCount(this.facets.Ratings?.["5.0"] ?? 0) },
{ id: 4, label: "⭐⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["4.0"] ?? 0) },
{ id: 3, label: "⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["3.0"] ?? 0) },
{ id: 2, label: "⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["2.0"] ?? 0) },
{ id: 1, label: "⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["1.0"] ?? 0) },
{ id: 0, label: this.$t("Unrated") + ratingCount(this.facets.Ratings?.["0.0"] ?? 0) },
{id: 5, label: "⭐⭐⭐⭐⭐" + ratingCount(this.facets.Ratings?.["5.0"] ?? 0)},
{id: 4, label: "⭐⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["4.0"] ?? 0)},
{id: 3, label: "⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["3.0"] ?? 0)},
{id: 2, label: "⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["2.0"] ?? 0)},
{id: 1, label: "⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["1.0"] ?? 0)},
{id: 0, label: this.$t("Unrated") + ratingCount(this.facets.Ratings?.["0.0"] ?? 0)},
]
},
},
@ -369,26 +435,44 @@ export default {
}
let urlParams = new URLSearchParams(window.location.search)
if (urlParams.has("keyword")) {
this.search.search_keywords = []
this.facets.Keywords = []
for (let x of urlParams.getAll("keyword")) {
this.search.search_keywords.push(Number.parseInt(x))
this.facets.Keywords.push({ id: x, name: "loading..." })
let initial_keyword = {id: Number.parseInt(x), name: "loading..."}
this.search.search_keywords.push(initial_keyword)
this.genericAPI(this.Models.KEYWORD, this.Actions.FETCH, {id: initial_keyword.id}).then((response) => {
let kw_index = this.search.search_keywords.findIndex((k => k.id === initial_keyword.id))
this.$set(this.search.search_keywords, kw_index, response.data)
this.$set(this.facets.Keywords, kw_index, response.data)
}).catch((err) => {
if (err.response.status === 404) {
let kw_index = this.search.search_keywords.findIndex((k => k.id === initial_keyword.id))
this.search.search_keywords.splice(kw_index, 1)
this.facets.Keywords.splice(kw_index, 1)
this.refreshData(false)
}
})
}
}
this.facets.Foods = []
for (let x of this.search.search_foods) {
this.facets.Foods.push({ id: x, name: "loading..." })
this.facets.Foods.push({id: x, name: "loading..."})
}
this.facets.Keywords = []
for (let x of this.search.search_keywords) {
this.facets.Keywords.push({ id: x, name: "loading..." })
this.facets.Keywords.push({id: x, name: "loading..."})
}
this.facets.Books = []
for (let x of this.search.search_books) {
this.facets.Books.push({ id: x, name: "loading..." })
this.facets.Books.push({id: x, name: "loading..."})
}
this.loadMealPlan()
this.refreshData(false)
})
@ -522,27 +606,28 @@ export default {
if (!this.ui.tree_select) {
return
}
let params = { hash: hash }
let params = {hash: hash}
if (facet) {
params[facet] = id
}
return this.genericGetAPI("api_get_facets", params).then((response) => {
this.facets = { ...this.facets, ...response.data.facets }
this.facets = {...this.facets, ...response.data.facets}
})
},
showSQL: function () {
let params = this.buildParams()
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {})
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {
})
},
// TODO refactor to combine with load KeywordChildren
loadFoodChildren({ action, parentNode, callback }) {
loadFoodChildren({action, parentNode, callback}) {
if (action === LOAD_CHILDREN_OPTIONS) {
if (this.facets?.cache_key) {
this.getFacets(this.facets.cache_key, "food", parentNode.id).then(callback())
}
}
},
loadKeywordChildren({ action, parentNode, callback }) {
loadKeywordChildren({action, parentNode, callback}) {
if (action === LOAD_CHILDREN_OPTIONS) {
if (this.facets?.cache_key) {
this.getFacets(this.facets.cache_key, "keyword", parentNode.id).then(callback())
@ -572,7 +657,7 @@ export default {
pageSize: this.ui.page_size,
}
if (!this.searchFiltered()) {
params.options = { query: { last_viewed: this.ui.recently_viewed } }
params.options = {query: {last_viewed: this.ui.recently_viewed}}
}
return params
},

File diff suppressed because it is too large Load Diff

View File

@ -59,7 +59,7 @@ export default {
addShopping() {
if (this.shopping) {
return
} // if item already in shopping list, excution handled after confirmation
} // if item already in shopping list, execution handled after confirmation
let params = {
id: this.item.id,
amount: 1,

View File

@ -1,5 +1,5 @@
<template>
<div>
<div style="cursor:pointer">
<a v-if="!button" class="dropdown-item" @click="clipboard"><i :class="icon"></i> {{ label }}</a>
<b-button v-if="button" @click="clipboard">{{ label }}</b-button>
</div>

View File

@ -1,5 +1,5 @@
<template>
<div>
<div style="cursor:pointer">
<a v-if="!button" class="dropdown-item" @click="downloadFile"><i :class="icon"></i> {{ label }}</a>
<b-button v-if="button" @click="downloadFile">{{ label }}</b-button>
</div>

View File

@ -1,5 +1,5 @@
<template>
<div>
<div style="cursor:pointer">
<a v-if="!button" class="dropdown-item" @click="downloadFile"><i :class="icon"></i> {{ label }}</a>
<b-button v-if="button" @click="downloadFile">{{ label }}</b-button>
</div>

View File

@ -116,7 +116,7 @@ export default {
},
addNew(e) {
this.$emit("new", e)
// could refactor as Promise - seems unecessary
// could refactor as Promise - seems unnecessary
setTimeout(() => {
this.search("")
}, 750)

View File

@ -7,8 +7,7 @@
</div>
<div class="col col-md-6 text-right" v-if="header">
<h4>
<i v-if="show_shopping && ShoppingRecipes.length > 0" class="fas fa-trash text-danger px-2"
@click="saveShopping(true)"></i>
<i v-if="show_shopping && ShoppingRecipes.length > 0" class="fas fa-trash text-danger px-2" @click="saveShopping(true)"></i>
<i v-if="show_shopping" class="fas fa-save text-success px-2" @click="saveShopping()"></i>
<i class="fas fa-shopping-cart px-2" @click="getShopping()"></i>
</h4>
@ -16,17 +15,16 @@
</div>
<div class="row text-right" v-if="ShoppingRecipes.length > 1">
<div class="col col-md-6 offset-md-6 text-right">
<b-form-select v-model="selected_shoppingrecipe" :options="ShoppingRecipes"
size="sm"></b-form-select>
<b-form-select v-model="selected_shoppingrecipe" :options="ShoppingRecipes" size="sm"></b-form-select>
</div>
</div>
<br v-if="header"/>
<br v-if="header" />
<div class="row no-gutter">
<div class="col-md-12">
<table class="table table-sm">
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
<template v-for="s in steps" >
<tr v-bind:key="s.id" v-if="s.show_as_header && s.name !== ''">
<template v-for="s in steps">
<tr v-bind:key="s.id" v-if="s.show_as_header && s.name !== '' && !add_shopping_mode">
<td colspan="5">
<b>{{ s.name }}</b>
</td>
@ -56,18 +54,18 @@
<script>
import Vue from "vue"
import {BootstrapVue} from "bootstrap-vue"
import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import IngredientComponent from "@/components/IngredientComponent"
import {ApiMixin, StandardToasts} from "@/utils/utils"
import { ApiMixin, StandardToasts } from "@/utils/utils"
Vue.use(BootstrapVue)
export default {
name: "IngredientCard",
mixins: [ApiMixin],
components: {IngredientComponent},
components: { IngredientComponent },
props: {
steps: {
type: Array,
@ -75,12 +73,12 @@ export default {
return []
},
},
recipe: {type: Number},
ingredient_factor: {type: Number, default: 1},
servings: {type: Number, default: 1},
detailed: {type: Boolean, default: true},
header: {type: Boolean, default: false},
add_shopping_mode: {type: Boolean, default: false},
recipe: { type: Number },
ingredient_factor: { type: Number, default: 1 },
servings: { type: Number, default: 1 },
detailed: { type: Boolean, default: true },
header: { type: Boolean, default: false },
add_shopping_mode: { type: Boolean, default: false },
},
data() {
return {
@ -99,7 +97,7 @@ export default {
value: x?.list_recipe,
text: x?.recipe_mealplan?.name,
recipe: x?.recipe_mealplan?.recipe ?? 0,
servings: x?.recipe_mealplan?.servings
servings: x?.recipe_mealplan?.servings,
}
})
.filter((x) => x?.recipe == this.recipe)

View File

@ -1,17 +1,22 @@
<template>
<div v-if="recipe.keywords.length > 0">
<span :key="k.id" v-for="k in recipe.keywords" class="pl-1">
<b-badge pill variant="light" class="font-weight-normal">{{k.label}}</b-badge>
<a :href="`${resolveDjangoUrl('view_search')}?keyword=${k.id}`"><b-badge pill variant="light"
class="font-weight-normal">{{ k.label }}</b-badge></a>
</span>
</div>
</template>
<script>
import {ResolveUrlMixin} from "@/utils/utils";
export default {
name: 'KeywordsComponent',
props: {
recipe: Object,
},
name: 'KeywordsComponent',
mixins: [ResolveUrlMixin],
props: {
recipe: Object,
},
}
</script>

View File

@ -77,7 +77,7 @@ export default {
},
mounted() {
this.id = Math.random()
this.$root.$on("change", this.storeValue) // boostrap modal placed at document so have to listen at root of component
this.$root.$on("change", this.storeValue) // bootstrap modal placed at document so have to listen at root of component
},
computed: {
buttonLabel() {

View File

@ -2,7 +2,7 @@
<div>
<b-modal :id="`shopping_${this.modal_id}`" hide-footer @show="loadRecipe">
<template v-slot:modal-title
><h4>{{ $t("Add_Servings_to_Shopping", { servings: servings }) }}</h4></template
><h4>{{ $t("Add_Servings_to_Shopping", { servings: recipe_servings }) }}</h4></template
>
<loading-spinner v-if="loading"></loading-spinner>
<div class="accordion" role="tablist" v-if="!loading">
@ -15,7 +15,7 @@
:steps="steps"
:recipe="recipe.id"
:ingredient_factor="ingredient_factor"
:servings="servings"
:servings="recipe_servings"
:show_shopping="true"
:add_shopping_mode="true"
:header="false"
@ -33,7 +33,7 @@
:steps="r.steps"
:recipe="r.recipe.id"
:ingredient_factor="ingredient_factor"
:servings="servings"
:servings="recipe_servings"
:show_shopping="true"
:add_shopping_mode="true"
:header="false"
@ -45,12 +45,19 @@
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
</b-card>
</div>
<div class="row mt-3 mb-3">
<div class="col-12 text-right">
<b-button class="mx-2" variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button>
<b-button class="mx-2" variant="success" @click="saveShopping">{{ $t("Save") }} </b-button>
</div>
</div>
<b-input-group class="my-3">
<b-input-group-prepend is-text>
{{ $t("Servings") }}
</b-input-group-prepend>
<b-form-spinbutton min="1" v-model="recipe_servings" inline style="height: 3em"></b-form-spinbutton>
<b-input-group-append>
<b-button variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button>
<b-button variant="success" @click="saveShopping">{{ $t("Save") }} </b-button>
</b-input-group-append>
</b-input-group>
</b-modal>
</div>
</template>
@ -71,25 +78,37 @@ export default {
mixins: [],
props: {
recipe: { required: true, type: Object },
servings: { type: Number },
servings: { type: Number, default: undefined },
modal_id: { required: true, type: Number },
},
data() {
return {
loading: true,
steps: [],
recipe_servings: 0,
recipe_servings: undefined,
add_shopping: [],
related_recipes: [],
}
},
mounted() {},
mounted() {
this.recipe_servings = this.servings
},
computed: {
ingredient_factor: function () {
return this.servings / this.recipe.servings || this.recipe_servings
return this.recipe_servings / this.recipe.servings
},
},
watch: {
recipe: {
handler() {
this.loadRecipe()
},
deep: true,
},
servings: function (newVal) {
this.recipe_servings = parseInt(newVal)
},
},
watch: {},
methods: {
loadRecipe: function () {
this.add_shopping = []
@ -109,7 +128,9 @@ export default {
.filter((x) => !x?.food?.food_onhand)
.map((x) => x.id),
]
this.recipe_servings = result.data?.servings
if (!this.recipe_servings) {
this.recipe_servings = result.data?.servings
}
this.loading = false
})
.then(() => {
@ -159,19 +180,27 @@ export default {
let shopping_recipe = {
id: this.recipe.id,
ingredients: this.add_shopping,
servings: this.servings,
servings: this.recipe_servings,
}
let apiClient = new ApiApiFactory()
apiClient
.shoppingRecipe(this.recipe.id, shopping_recipe)
.then((result) => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
this.$emit("finish")
})
.catch((err) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
this.$bvModal.hide(`shopping_${this.modal_id}`)
},
},
}
</script>
<style>
.b-form-spinbutton.form-control {
background-color: #e9ecef;
border: 1px solid #ced4da;
}
</style>

View File

@ -1,139 +1,150 @@
<template>
<div id="shopping_line_item">
<b-container fluid class="pr-0 pl-1 pl-md-3">
<!-- summary rows -->
<b-row align-h="start">
<b-col cols="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true">
<b-row align-h="start">
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
v-if="settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
</div>
</b-button>
</b-col>
<b-col cols="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
@click.stop="$emit('open-context-menu', $event, entries)">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
:class="settings.left_handed ? 'dropdown-spacing' : ''"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret">
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
</b-col>
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
<b-col cols="8" md="9">
<b-row class="d-flex h-100">
<b-col cols="5" md="3" class="d-flex align-items-center" v-if="Object.entries(formatAmount).length == 1">
<strong class="mr-1">{{ Object.entries(formatAmount)[0][1] }}</strong> {{
Object.entries(formatAmount)[0][0]
}}
</b-col>
<b-col cols="5" md="3" class="d-flex flex-column" v-if="Object.entries(formatAmount).length != 1">
<div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }} &ensp;
{{ x[0] }}
</div>
</b-col>
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatFood }}
</b-col>
<b-col cols="3" data-html2canvas-ignore="true"
class="align-items-center d-none d-md-flex justify-content-end">
<b-button size="sm" @click="showDetails = !showDetails" class="p-0 mr-0 mr-md-2 p-md-2 text-decoration-none"
variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate"
:class="showDetails ? 'rotated' : ''"></i> <span
class="d-none d-md-inline-block">{{ $t('Details') }}</span>
</div>
</b-button>
</b-col>
</b-row>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none"
v-if="!settings.left_handed">
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
</div>
</b-button>
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
</b-row>
<b-row align-h="center" class="d-none d-md-flex">
<b-col cols="12">
<div class="small text-muted text-truncate">{{ formatHint }}</div>
</b-col>
</b-row>
<!-- detail rows -->
<div class="card no-body mb-1 pt-2 align-content-center shadow-sm" v-if="showDetails">
<div v-for="(e, x) in entries" :key="e.id">
<b-row class="small justify-content-around">
<b-col cols="auto" md="4" class="overflow-hidden text-nowrap">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
@click.stop="$emit('open-context-menu', $event, entries)">
<i class="fas fa-ellipsis-v fa-lg"></i>
class="btn btn-link btn-sm m-0 p-0 pl-2"
style="text-overflow: ellipsis"
@click.stop="openRecipeCard($event, e)"
@mouseover="openRecipeCard($event, e)">
{{ formatOneRecipe(e) }}
</button>
</div>
</b-col>
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
<b-col cols="8" md="9">
<b-row class="d-flex h-100" @click.stop="$emit('open-context-menu', $event, entries)">
<b-col cols="6" md="6" class="d-flex align-items-center" v-if="Object.entries(formatAmount).length == 1">
<div><strong>{{ Object.entries(formatAmount)[0][1] }}</strong> &ensp;
{{ Object.entries(formatAmount)[0][0] }}
</div>
</b-col>
<b-col cols="6" md="6" class="d-flex flex-column" v-if="Object.entries(formatAmount).length != 1">
<div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }} &ensp;
{{ x[0] }}
</div>
</b-col>
<b-col cols="6" md="3" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatFood }}
</b-col>
<b-col cols="3" data-html2canvas-ignore="true" class="align-items-center d-none d-md-flex">
<b-button size="sm" @click="showDetails = !showDetails" class="p-0 mr-0 mr-md-2 p-md-2" variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate"
:class="showDetails ? 'rotated' : ''"></i> <span
class="d-none d-md-inline-block">{{ $t('Details') }}</span>
</div>
</b-button>
</b-col>
</b-row>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none">
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
</div>
</b-button>
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
</b-row>
<b-row align-h="center" class="d-none d-md-flex">
<b-col cols="12">
<div class="small text-muted text-truncate">{{ formatHint }}</div>
</b-col>
</b-row>
</b-container>
<!-- detail rows -->
<div class="card no-body mb-1 pt-2 align-content-center ml-2" v-if="showDetails">
<b-container fluid>
<div v-for="(e, x) in entries" :key="e.id">
<b-row class="small justify-content-around">
<b-col cols="auto" md="4" class="overflow-hidden text-nowrap">
</b-col>
<b-col cols="auto" md="4" class="text-muted">{{ formatOneMealPlan(e) }}</b-col>
<b-col cols="auto" md="4" class="text-muted text-right overflow-hidden text-nowrap pr-4">
{{ formatOneCreatedBy(e) }}
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
</b-col>
</b-row>
<b-row align-h="start">
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
v-if="settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
:checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
<b-col cols="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
@click.stop="$emit('open-context-menu', $event, e)">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn btn-link btn-sm m-0 p-0"
style="text-overflow: ellipsis"
@click.stop="openRecipeCard($event, e)"
@mouseover="openRecipeCard($event, e)">
{{ formatOneRecipe(e) }}
:class="settings.left_handed ? 'dropdown-spacing' : ''"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret">
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</b-col>
<b-col cols="auto" md="4" class="text-muted">{{ formatOneMealPlan(e) }}</b-col>
<b-col cols="auto" md="4" class="text-muted text-right overflow-hidden text-nowrap">
{{ formatOneCreatedBy(e) }}
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
</b-col>
</b-row>
<b-row align-h="start">
<b-col cols="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
@click.stop="$emit('open-context-menu', $event, e)">
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
</b-col>
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
<b-col cols="8" md="9">
<b-row class="d-flex justify-content-around">
<b-col cols="6" md="6" class="d-flex align-items-center">
<div>{{ formatOneAmount(e) }} &ensp;
{{ formatOneUnit(e) }}
</div>
</b-col>
<b-col cols="6" md="3" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatOneFood(e) }}
</b-col>
<b-col cols="12" class="d-flex d-md-none">
<div class="small text-muted text-truncate" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
</b-col>
</b-row>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
:checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
</b-row>
<hr class="w-75" v-if="x !== entries.length -1"/>
<div class="pb-4" v-if="x === entries.length -1"></div>
</div>
</b-container>
</div>
</b-col>
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
<b-col cols="8" md="9">
<b-row class="d-flex align-items-center h-100">
<b-col cols="5" md="3" class="d-flex align-items-center">
<strong class="mr-1">{{ formatOneAmount(e) }}</strong> {{ formatOneUnit(e) }}
</b-col>
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatOneFood(e) }}
</b-col>
<b-col cols="12" class="d-flex d-md-none">
<div class="small text-muted text-truncate" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
</b-col>
</b-row>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none"
v-if="!settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
:checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
</b-row>
<hr class="w-75" v-if="x !== entries.length -1"/>
<div class="pb-4" v-if="x === entries.length -1"></div>
</div>
</div>
<hr class="m-1" v-if="!showDetails"/>
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width: 300">
@ -177,6 +188,7 @@ export default {
entries: {
type: Array,
},
settings: Object,
groupby: {type: String},
},
data() {
@ -355,4 +367,11 @@ export default {
font-size: 1rem !important;
font-weight: 500 !important;
}
@media (max-width: 768px) {
.dropdown-spacing {
padding-left: 0 !important;
padding-right: 0 !important;
}
}
</style>

View File

@ -213,7 +213,7 @@
"IgnoreThis": "{food} nicht automatisch zur Einkaufsliste hinzufügen",
"shopping_auto_sync": "Automatische Synchronisierung",
"shopping_share_desc": "Benutzer sehen all Einträge, die du zur Einkaufsliste hinzufügst. Sie müssen dich hinzufügen, damit du Ihre Einträge sehen kannst.",
"IgnoredFood": "{food} beim nächsten Einkauf ignorieren",
"IgnoredFood": "{food} beim nächsten Einkauf ignorieren.",
"Add_Servings_to_Shopping": "{servings} Portionen zum Einkauf hinzufügen",
"Inherit": "Vererben",
"InheritFields": "Feldwerte vererben",
@ -233,8 +233,8 @@
"AddToShopping": "Zur Einkaufsliste hinzufügen",
"FoodOnHand": "Sie haben {food} vorrätig.",
"DeleteShoppingConfirm": "Möchten Sie wirklich alle {food} von der Einkaufsliste zu entfernen?",
"err_moving_resource": "Ein Fehler trat während des Verschiebens einer Ressource auf!",
"err_merging_resource": "Beim Zusammenführen einer Relssource trat ein Fehler auf!",
"err_moving_resource": "Während des Verschiebens einer Resource ist ein Fehler aufgetreten!",
"err_merging_resource": "Beim Zusammenführen einer Ressource ist ein Fehler aufgetreten!",
"success_moving_resource": "Ressource wurde erfolgreich verschoben!",
"success_merging_resource": "Ressource wurde erfolgreich zusammengeführt!",
"Shopping_Categories": "Einkaufskategorien",
@ -245,7 +245,7 @@
"FoodNotOnHand": "Sie haben kein {food} vorrätig.",
"Undefined": "nicht definiert",
"AddFoodToShopping": "{food} zur Einkaufsliste hinzufügen",
"RemoveFoodFromShopping": "{food} von der Einkaufsliste entfernen",
"RemoveFoodFromShopping": "{food} von der Einkaufsliste entfernen",
"Search Settings": "Sucheinstellungen",
"shopping_auto_sync_desc": "Bei 0 wird Auto-Sync deaktiviert. Beim Betrachten einer Einkaufsliste wird die Liste alle gesetzten Sekunden aktualisiert, um mögliche Änderungen anderer zu zeigen. Nützlich, wenn mehrere Personen einkaufen und mobile Daten nutzen.",
"MoveCategory": "Verschieben nach: ",

View File

@ -287,5 +287,7 @@
"sql_debug": "SQL Debug",
"remember_search": "Remember Search",
"remember_hours": "Hours to Remember",
"tree_select": "Use Tree Selection"
"tree_select": "Use Tree Selection",
"left_handed": "Left-handed mode",
"left_handed_help": "Will optimize the UI for use with your left hand."
}

View File

@ -284,5 +284,6 @@
"QuickEntry": "Szybki wpis",
"related_recipes": "Powiązane przepisy",
"today_recipes": "Dzisiejsze przepisy",
"Search Settings": "Ustawienia wyszukiwania"
"Search Settings": "Ustawienia wyszukiwania",
"Pin": "Pin"
}

View File

@ -11,7 +11,7 @@
"all_fields_optional": "所有字段都是可选的,可以留空。",
"convert_internal": "转换为内部菜谱",
"show_only_internal": "仅显示内部菜谱",
"Log_Recipe_Cooking": "菜谱烹饪录",
"Log_Recipe_Cooking": "菜谱烹饪录",
"External_Recipe_Image": "外部菜谱图像",
"Add_to_Shopping": "添加到购物",
"Add_to_Plan": "添加到计划",
@ -54,7 +54,7 @@
"Categories": "分类",
"Category": "分类",
"Selected": "选定",
"min": "",
"min": "分钟",
"Servings": "份量",
"Waiting": "等待",
"Preparation": "准备",
@ -74,7 +74,7 @@
"Print": "打印",
"Settings": "设置",
"or": "或",
"and": "",
"and": "",
"Information": "更多信息",
"Download": "下载",
"Create": "创建",
@ -165,5 +165,115 @@
"Create_New_Shopping Category": "创建新的购物类别",
"Automate": "自动化",
"Empty": "空的",
"SupermarketName": "超市名"
"SupermarketName": "超市名",
"NotInShopping": "购物清单中没有 {food}。",
"Drag_Here_To_Delete": "拖动此处可删除",
"DeleteShoppingConfirm": "确定要移除购物清单中所有 {food} 吗?",
"ShowDelayed": "显示延迟的项目",
"mealplan_autoexclude_onhand": "排除入手的食物",
"shopping_share_desc": "用户将看到您添加到购物清单中的所有商品。他们必须添加你才能看到他们清单上的内容。",
"shopping_auto_sync_desc": "设置为0将禁用自动同步。当查看购物列表时该列表每隔一秒更新一次以同步其他人可能做出的更改。在多人购物时很有用但会使用移动数据。",
"err_merge_self": "无法将项目与自身合并",
"CategoryInstruction": "拖动类别可更改出现在购物清单中的订单类别。",
"csv_prefix_help": "将清单复制到剪贴板时要添加的前缀。",
"remember_search": "记住搜索",
"Root": "根",
"Instructions": "说明",
"Period": "周期",
"Plan_Period_To_Show": "显示星期、月或年",
"Periods": "周期",
"Plan_Show_How_Many_Periods": "要显示多少个周期",
"Starting_Day": "一周中的第一天",
"Meal_Types": "用餐类型",
"Make_header": "显示注意事项",
"Color": "颜色",
"New_Meal_Type": "新用餐类型",
"Pin": "固定",
"Planner_Settings": "计划者设置",
"Meal_Type": "用餐类型",
"Clone": "复制",
"Title_or_Recipe_Required": "需要选择标题或菜谱",
"Export_As_ICal": "将当前周期导出为 iCal 格式",
"Week_Numbers": "周数",
"Show_Week_Numbers": "显示周数?",
"Coming_Soon": "即将到来",
"New_Cookbook": "新烹饪书",
"Hide_Keyword": "隐藏关键词",
"Export_To_ICal": "导出 .ics",
"Added_To_Shopping_List": "添加到购物清单",
"Cannot_Add_Notes_To_Shopping": "无法将笔记添加到购物清单",
"Shopping_List_Empty": "您的购物列表当前为空,您可以通过用餐计划条目的上下文菜单添加项目(右键单击卡片或左键单击菜单图标)",
"Next_Period": "下期",
"Current_Period": "本期",
"Next_Day": "第二天",
"Previous_Period": "上期",
"Previous_Day": "前一天",
"remember_hours": "需要记住的时间",
"tree_select": "使用树形选择",
"Make_Ingredient": "显示材料",
"Note": "笔记",
"Added_on": "添加到",
"AddToShopping": "添加到购物清单",
"IngredientInShopping": "此材料已在购物清单。",
"OnHand": "目前",
"FoodOnHand": "你手上有 {food}。",
"FoodNotOnHand": "你还没有 {food}。",
"Undefined": "未定义的",
"Create_Meal_Plan_Entry": "创建用餐计划条目",
"Edit_Meal_Plan_Entry": "编辑用餐计划条目",
"Title": "标题",
"Week": "星期",
"Month": "月份",
"Year": "年",
"Planner": "计划者",
"Meal_Type_Required": "用餐类型是必需的",
"AddFoodToShopping": "添加 {food} 到购物清单",
"RemoveFoodFromShopping": "从购物清单中移除 {food}",
"IgnoredFood": "已忽略购买 {food}。",
"Add_Servings_to_Shopping": "添加 {servings} 份到购物",
"Inherit": "继承",
"InheritFields": "继承字段值",
"FoodInherit": "食物可继承的字段",
"ShowUncategorizedFood": "显示未定义",
"GroupBy": "分组",
"MoveCategory": "移动到: ",
"IgnoreThis": "永不自动添加 {food} 到购物",
"DelayFor": "延迟 {hours} 小时",
"Warning": "警告",
"NoCategory": "未选择分类。",
"Completed": "完成",
"OfflineAlert": "您处于离线状态,购物清单可能无法同步。",
"shopping_share": "分享购物清单",
"shopping_auto_sync": "自动同步",
"mealplan_autoadd_shopping": "自动添加用餐计划",
"mealplan_autoinclude_related": "添加相关的菜谱",
"default_delay": "默认延迟时间",
"mealplan_autoadd_shopping_desc": "自动将用餐计划配料添加到购物清单中。",
"mealplan_autoexclude_onhand_desc": "将用餐计划添加到购物清单时(手动或自动),排除当前手头上的配料。",
"mealplan_autoinclude_related_desc": "将用餐计划(手动或自动)添加到购物清单时,包括所有相关菜谱。",
"default_delay_desc": "延迟购物清单条目的默认小时数。",
"err_move_self": "无法将项目移动到自身",
"nothing": "无事可做",
"show_sql": "显示 SQL",
"filter_to_supermarket_desc": "默认情况下,过滤购物清单只包括所选超市的类别。",
"shopping_recent_days_desc": "显示最近几天的购物清单条目。",
"shopping_recent_days": "最近几天",
"create_shopping_new": "添加到新的购物清单",
"download_pdf": "下载 PDF",
"download_csv": "下载 CSV",
"csv_delim_help": "用于 CSV 导出的分隔符。",
"csv_delim_label": "CSV 分隔符",
"SuccessClipboard": "购物清单已复制到剪贴板",
"copy_to_clipboard": "复制到剪贴板",
"csv_prefix_label": "清单前缀",
"copy_markdown_table": "复制为 Markdown 表格",
"in_shopping": "在购物清单上",
"DelayUntil": "推迟到",
"mark_complete": "标记完成",
"QuickEntry": "快速入口",
"shopping_add_onhand_desc": "在核对购物清单时,将食物标记为“入手”。",
"shopping_add_onhand": "自动入手",
"related_recipes": "相关的菜谱",
"today_recipes": "今日菜谱",
"sql_debug": "调试 SQL"
}