Merge branch 'mealie-next' into add-count-to-recipe-explorer-header

This commit is contained in:
Jannon Frank 2025-01-25 23:32:55 -05:00 committed by GitHub
commit 5761702bb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
129 changed files with 3633 additions and 2094 deletions

View file

@ -12,7 +12,7 @@ repos:
exclude: ^tests/data/ exclude: ^tests/data/
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.8.6 rev: v0.9.2
hooks: hooks:
- id: ruff - id: ruff
- id: ruff-format - id: ruff-format

View file

@ -0,0 +1,8 @@
!!! info
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
Mealie supports adding the ingredients of a recipe to your [Bring](https://www.getbring.com/) shopping list, as you can
see [here](https://docs.mealie.io/documentation/getting-started/features/#recipe-actions).
However, for this to work, your Mealie instance needs to be exposed to the open Internet so that the Bring servers can access its information. If you don't want your server to be publicly accessible for security reasons, you can use the [Mealie-Bring-API](https://github.com/felixschndr/mealie-bring-api) written by a community member. This integration is entirely local and does not require any service to be exposed to the Internet.
This is a small web server that runs locally next to your Mealie instance, and instead of Bring pulling the data from you, it pushes the data to Bring. [Check out the project](https://github.com/felixschndr/mealie-bring-api) for more information and installation instructions.

View file

@ -10,7 +10,7 @@
Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many Identity Providers (IdP), including: Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many Identity Providers (IdP), including:
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect) - [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/) - [Authelia](https://www.authelia.com/integration/openid-connect/mealie/)
- [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc) - [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc)
- [Okta](https://www.okta.com/openid-connect/) - [Okta](https://www.okta.com/openid-connect/)

View file

@ -1,9 +1,15 @@
# Frequently Asked Questions # Frequently Asked Questions
## How do I enable "smart" ingredient handling? ## Features and Functionality
??? question "How do I enable 'smart' ingredient handling?"
### How do I enable "smart" ingredient handling?
You might have noticed that scaling up a recipe or making a shopping list doesn't by default handle the ingredients in a way you might expect. Depending on your settings, scaling up might yield things like `2 1 cup broth` instead of `2 cup broth`. And, making shopping lists from recipes that have shared ingredients can yield multiple lines of the same ingredient. **But**, Mealie has a mechanism to intelligently handle ingredients and make your day better. How? You might have noticed that scaling up a recipe or making a shopping list doesn't by default handle the ingredients in a way you might expect. Depending on your settings, scaling up might yield things like `2 1 cup broth` instead of `2 cup broth`. And, making shopping lists from recipes that have shared ingredients can yield multiple lines of the same ingredient. **But**, Mealie has a mechanism to intelligently handle ingredients and make your day better. How?
### Set up your Foods and Units
<p style="font-size: 0.75rem; font-weight: 500;">Set up your Foods and Units</p>
Do the following just **once**. Doing this applies to your whole group, so be careful. Do the following just **once**. Doing this applies to your whole group, so be careful.
1. Click on your name in the upper left corner to get to your settings 1. Click on your name in the upper left corner to get to your settings
@ -15,7 +21,8 @@ Do the following just **once**. Doing this applies to your whole group, so be ca
Initial seeding of Units is pretty complete, but there are many Foods in the world. You'll probably find that you need to add Foods to the database during parsing for the first several recipes. Once you have a well-populated Food database, there are API routes to parse ingredients automatically in bulk. But this is not a good idea without a very complete set of Foods. Initial seeding of Units is pretty complete, but there are many Foods in the world. You'll probably find that you need to add Foods to the database during parsing for the first several recipes. Once you have a well-populated Food database, there are API routes to parse ingredients automatically in bulk. But this is not a good idea without a very complete set of Foods.
### Set up Recipes to use Foods and Units <p style="font-size: 0.75rem; font-weight: 500;">Set up Recipes to use Foods and Units</p>
Do the following for each recipe you want to intelligently handle ingredients. Do the following for each recipe you want to intelligently handle ingredients.
1. Go to a recipe 1. Go to a recipe
@ -33,40 +40,62 @@ Do the following for each recipe you want to intelligently handle ingredients.
Scaling up this recipe or adding it to a Shopping List will now smartly take care of ingredient amounts and duplicate combinations. Scaling up this recipe or adding it to a Shopping List will now smartly take care of ingredient amounts and duplicate combinations.
## Is it safe to upgrade Mealie?
Yes. If you are using the v1 branches (including beta), you can upgrade to the latest version of Mealie without performing a site Export/Restore. This process was required in previous versions of Mealie, however we've automated the database migration process to make it easier to upgrade. Note that if you were using the v0.5.x version, you CANNOT upgrade to the latest version automatically. You must follow the migration instructions in the documentation. ??? question "How do I enable Nutritional Values?"
- [Migration From v0.5.x](./migrating-to-mealie-v1.md) ### How do I enable Nutritional Values?
## How can I change the theme? Mealie can store Nutritional Information for Recipes. Please note that the values you enter are static for the recipe and no scaling is being done when changing Servings / Yield.
You can change the theme by settings the environment variables. Do the following to enable Nutritional Values on individual Recipes, or to modify your Household Recipe Preferences
- [Backend Config - Themeing](./installation/backend-config.md#themeing) **Show Nutritional Values on a Single Recipe**
## How can I change the login session timeout? 1. Go to a recipe
2. Click the Edit button/icon
3. Click the Recipe Settings gear and select `Show Nutritional Values`
4. Scroll down to manually fill out the Nutritional Values
5. Save
Login session can be configured by setting the `TOKEN_TIME` variable on the backend container. **Show Nutritional Values by default**
- [Backend Config](./installation/backend-config.md) 1. Click your username in the top left
2. Click the 'Household Settings' button
3. Under 'Household Recipe Preferences', click to select 'Show nutrition information'
4. Click 'Update'
## Can I serve Mealie on a subpath?
No. Due to limitations from the JavaScript Framework, Mealie doesn't support serving Mealie on a subpath. ??? question "Why Link Ingredients to a Recipe Step?"
## Can I install Mealie without docker? ### Why Link Ingredients to a Recipe Step?
Yes, you can install Mealie on your local machine. HOWEVER, it is recommended that you don't. Managing non-system versions of python, node, and npm is a pain. Moreover, updating and upgrading your system with this configuration is unsupported and will likely require manual interventions. Mealie allows you to link ingredients to specific steps in a recipe, ensuring you know exactly when to add each ingredient during the cooking process.
**Link Ingredients to Steps in a Recipe**
1. Go to a recipe
2. Click the Edit button/icon
3. Scroll down to the step you want to link ingredients to
4. Click the ellipsis button next to the step and click 'Link Ingredients'
5. Check off the Ingredient(s) that you want to link to that step
6. Optionally, click 'Next step' to continue linking remaining ingredients to steps, or click 'Save' to Finish
7. Click 'Save' on the Recipe
You can optionally link the same ingredient to multiple steps, which is useful for prepping an ingredient in one step and using it in another.
??? question "What is fuzzy search and how do I use it?"
### What is fuzzy search and how do I use it?
## What is fuzzy search and how do I use it?
Mealie can use fuzzy search, which is robust to minor typos. For example, searching for "brocolli" will still find your recipe for "broccoli soup". But fuzzy search is only functional on a Postgres database backend. To enable fuzzy search you will need to migrate to Postgres: Mealie can use fuzzy search, which is robust to minor typos. For example, searching for "brocolli" will still find your recipe for "broccoli soup". But fuzzy search is only functional on a Postgres database backend. To enable fuzzy search you will need to migrate to Postgres:
1. Backup your database and download the .zip file (same as when [migrating](./migrating-to-mealie-v1.md)) 1. Backup your database and download the .zip file (same as when [migrating](./migrating-to-mealie-v1.md))
2. Set up a [Postgres](./installation/postgres.md) instance of Mealie 2. Set up a [Postgres](./installation/postgres.md) instance of Mealie
3. Upload the backup .zip and click to apply it (as as migration) 3. Upload the backup .zip and click to apply it (as as migration)
## How can I attach an image or video to a Recipe? ??? question "How can I attach an image or video to a Recipe?"
### How can I attach an image or video to a Recipe?
Mealie's Recipe Steps and other fields support markdown syntax and therefore support images and videos. To attach an image to the recipe, you can upload it as an asset and use the provided copy button to generate the html image tag required to render the image. For videos, Mealie provides no way to host videos. You'll need to host your videos with another provider and embed them in your recipe. Generally, the video provider will provide a link to the video and the html tag required to render the video. For example, YouTube provides the following link that works inside a step. You can adjust the width and height attributes as necessary to ensure a fit. Mealie's Recipe Steps and other fields support markdown syntax and therefore support images and videos. To attach an image to the recipe, you can upload it as an asset and use the provided copy button to generate the html image tag required to render the image. For videos, Mealie provides no way to host videos. You'll need to host your videos with another provider and embed them in your recipe. Generally, the video provider will provide a link to the video and the html tag required to render the video. For example, YouTube provides the following link that works inside a step. You can adjust the width and height attributes as necessary to ensure a fit.
@ -74,7 +103,45 @@ Mealie's Recipe Steps and other fields support markdown syntax and therefore sup
<iframe width="560" height="315" src="https://www.youtube.com/embed/nAUwKeO93bY" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> <iframe width="560" height="315" src="https://www.youtube.com/embed/nAUwKeO93bY" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
``` ```
## How can I unlock my account? ## Customization and Configuration
??? question "How can I change the theme?"
### How can I change the theme?
You can change the theme by settings the environment variables.
- [Backend Config - Themeing](./installation/backend-config.md#themeing)
??? question "How can I change the login session timeout?"
### How can I change the login session timeout?
Login session can be configured by setting the `TOKEN_TIME` variable on the backend container.
- [Backend Config](./installation/backend-config.md)
??? question "Can I serve Mealie on a subpath?"
### Can I serve Mealie on a subpath?
No. Due to limitations from the JavaScript Framework, Mealie doesn't support serving Mealie on a subpath.
??? question "Can I install Mealie without docker?"
### Can I install Mealie without docker?
Yes, you can install Mealie on your local machine. HOWEVER, it is recommended that you don't. Managing non-system versions of python, node, and npm is a pain. Moreover, updating and upgrading your system with this configuration is unsupported and will likely require manual interventions.
## Account Management
??? question "How can I unlock my account?"
### How can I unlock my account?
If your account has been locked by bad password attempts, you can use an administrator account to unlock another account. Alternatively, you can unlock all accounts via a script within the container. If your account has been locked by bad password attempts, you can use an administrator account to unlock another account. Alternatively, you can unlock all accounts via a script within the container.
@ -84,7 +151,10 @@ docker exec -it mealie bash
python /app/mealie/scripts/reset_locked_users.py python /app/mealie/scripts/reset_locked_users.py
``` ```
## How can I reset admin privileges for my account?
??? question "How can I reset admin privileges for my account?"
### How can I reset admin privileges for my account?
If you've lost admin privileges and no other admin can restore them, you can use the Command Line Interface (CLI) to grant admin access. If you've lost admin privileges and no other admin can restore them, you can use the Command Line Interface (CLI) to grant admin access.
@ -94,7 +164,10 @@ docker exec -it mealie bash
python /app/mealie/scripts/make_admin.py python /app/mealie/scripts/make_admin.py
``` ```
## How can I change my password?
??? question "How can I change my password?"
### How can I change my password?
You can change your password by going to the user profile page and clicking the "Change Password" button. Alternatively you can use the following script to change your password via the CLI if you are locked out of your account. You can change your password by going to the user profile page and clicking the "Change Password" button. Alternatively you can use the following script to change your password via the CLI if you are locked out of your account.
@ -104,11 +177,19 @@ docker exec -it mealie bash
python /app/mealie/scripts/change_password.py python /app/mealie/scripts/change_password.py
``` ```
## I can't log in with external auth. How can I change my authentication method?
??? question "I can't log in with external auth. How can I change my authentication method?"
### I can't log in with external auth. How can I change my authentication method?
Follow the [steps above](#how-can-i-change-my-password) for changing your password. You will be prompted if you would like to switch your authentication method back to local auth so you can log in again. Follow the [steps above](#how-can-i-change-my-password) for changing your password. You will be prompted if you would like to switch your authentication method back to local auth so you can log in again.
## How do private groups, households, and recipes work?
## Collaboration and Privacy
??? question "How do private groups, households, and recipes work?"
### How do private groups, households, and recipes work?
Managing private groups and recipes can be confusing. The following diagram and notes should help explain how they work to determine if a recipe can be shared publicly. Managing private groups and recipes can be confusing. The following diagram and notes should help explain how they work to determine if a recipe can be shared publicly.
@ -145,7 +226,22 @@ stateDiagram-v2
For more information on public access, check out the [Permissions and Public Access guide](./usage/permissions-and-public-access.md). For more information on groups vs. households, check out the [Groups and Households](./features.md#groups-and-households) section in the Features guide. For more information on public access, check out the [Permissions and Public Access guide](./usage/permissions-and-public-access.md). For more information on groups vs. households, check out the [Groups and Households](./features.md#groups-and-households) section in the Features guide.
## Can I use fail2ban with Mealie?
## Security and Maintenance
??? question "How can I use Mealie externally?"
### How can I use Mealie externally
Exposing Mealie or any service to the internet can pose significant security risks. Before proceeding, carefully evaluate the potential impacts on your system. Due to the unique nature of each network, we cannot provide specific steps for your setup.
There is a community guide available for one way to potentially set this up, and you could reach out on Discord for further discussion on what may be best for your network.
??? question "Can I use fail2ban with Mealie?"
### Can I use fail2ban with Mealie?
Yes, Mealie is configured to properly forward external IP addresses into the `mealie.log` logfile. Note that due to restrictions in docker, IP address forwarding only works on Linux. Yes, Mealie is configured to properly forward external IP addresses into the `mealie.log` logfile. Note that due to restrictions in docker, IP address forwarding only works on Linux.
Your fail2ban usage should look like the following: Your fail2ban usage should look like the following:
@ -154,10 +250,42 @@ Use datepattern : %d-%b-%y %H:%M:%S : Day-MON-Year2 24hour:Minute:Second
Use failregex line : ^ERROR:\s+Incorrect username or password from <HOST> Use failregex line : ^ERROR:\s+Incorrect username or password from <HOST>
``` ```
## Why an API?
??? question "Is it safe to upgrade Mealie?"
### Is it safe to upgrade Mealie?
Yes. If you are using the v1 branches (including beta), you can upgrade to the latest version of Mealie without performing a site Export/Restore. This process was required in previous versions of Mealie, however we've automated the database migration process to make it easier to upgrade. Note that if you were using the v0.5.x version, you CANNOT upgrade to the latest version automatically. You must follow the migration instructions in the documentation.
- [Migration From v0.5.x](./migrating-to-mealie-v1.md)
## Technical Considerations
??? question "Why setup Email?"
### Why setup Email?
Mealie uses email to send account invites and password resets. If you don't use these features, you don't need to set up email. There are also other methods to perform these actions that do not require the setup of Email.
Email settings can be adjusted via environment variables on the backend container:
- [Backend Config](./installation/backend-config.md)
Note that many email providers (e.g., Gmail, Outlook) are disabling SMTP Auth and requiring Modern Auth, which Mealie currently does not support. You may need to use an SMTP relay or third-party SMTP provider, such as SMTP2GO.
??? question "Why an API?"
### Why an API?
An API allows integration into applications like [Home Assistant](https://www.home-assistant.io/) that can act as notification engines to provide custom notifications based on Meal Plan data to remind you to defrost the chicken, marinate the steak, or start the CrockPot. Additionally, you can access nearly any backend service via the API giving you total control to extend the application. To explore the API spin up your server and navigate to http://yourserver.com/docs for interactive API documentation. An API allows integration into applications like [Home Assistant](https://www.home-assistant.io/) that can act as notification engines to provide custom notifications based on Meal Plan data to remind you to defrost the chicken, marinate the steak, or start the CrockPot. Additionally, you can access nearly any backend service via the API giving you total control to extend the application. To explore the API spin up your server and navigate to http://yourserver.com/docs for interactive API documentation.
## Why a database?
??? question "Why a database?"
### Why a database?
Some users of static-site generator applications like ChowDown have expressed concerns about their data being stuck in a database. Considering this is a new project, it is a valid concern to be worried about your data. Mealie specifically addresses this concern by providing automatic daily backups that export your data in json, plain-text markdown files, and/or custom Jinja2 templates. **This puts you in control of how your data is represented** when exported from Mealie, which means you can easily migrate to any other service provided Mealie doesn't work for you. Some users of static-site generator applications like ChowDown have expressed concerns about their data being stuck in a database. Considering this is a new project, it is a valid concern to be worried about your data. Mealie specifically addresses this concern by providing automatic daily backups that export your data in json, plain-text markdown files, and/or custom Jinja2 templates. **This puts you in control of how your data is represented** when exported from Mealie, which means you can easily migrate to any other service provided Mealie doesn't work for you.
As to why we need a database? As to why we need a database?
@ -165,7 +293,13 @@ As to why we need a database?
- **Developer Experience:** Without a database, a lot of the work to maintain your data is taken on by the developer instead of a battle-tested platform for storing data. - **Developer Experience:** Without a database, a lot of the work to maintain your data is taken on by the developer instead of a battle-tested platform for storing data.
- **Multi User Support:** With a solid database as backend storage for your data, Mealie can better support multi-user sites and avoid read/write access errors when multiple actions are taken at the same time. - **Multi User Support:** With a solid database as backend storage for your data, Mealie can better support multi-user sites and avoid read/write access errors when multiple actions are taken at the same time.
## Why is there no "Keep Screen Alive" button when I access a recipe?
## Usability
??? question "Why is there no 'Keep Screen Alive' button when I access a recipe?"
### Why is there no "Keep Screen Alive" button when I access a recipe?
You've perhaps visited the Mealie Demo and noticed that it had a "Keep Screen Alive" button, but it doesn't show up in your own Mealie instance. You've perhaps visited the Mealie Demo and noticed that it had a "Keep Screen Alive" button, but it doesn't show up in your own Mealie instance.
There are typically two possible reasons for this: There are typically two possible reasons for this:
1. You're accessing your Mealie instance without using HTTPS. The Wake Lock API is only available if HTTPS is used. Read more here: https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API 1. You're accessing your Mealie instance without using HTTPS. The Wake Lock API is only available if HTTPS is used. Read more here: https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API

View file

@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do: We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
1. Take a backup just in case! 1. Take a backup just in case!
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.4.2` 2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.5.0`
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access. 3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
4. Restart the container 4. Restart the container

View file

@ -7,7 +7,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
```yaml ```yaml
services: services:
mealie: mealie:
image: ghcr.io/mealie-recipes/mealie:v2.4.2 # (3) image: ghcr.io/mealie-recipes/mealie:v2.5.0 # (3)
container_name: mealie container_name: mealie
restart: always restart: always
ports: ports:

View file

@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
```yaml ```yaml
services: services:
mealie: mealie:
image: ghcr.io/mealie-recipes/mealie:v2.4.2 # (3) image: ghcr.io/mealie-recipes/mealie:v2.5.0 # (3)
container_name: mealie container_name: mealie
restart: always restart: always
ports: ports:

File diff suppressed because one or more lines are too long

View file

@ -48,6 +48,7 @@ markdown_extensions:
- name: mermaid - name: mermaid
class: mermaid class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.details
extra_css: extra_css:
- assets/stylesheets/custom.css - assets/stylesheets/custom.css
extra_javascript: extra_javascript:

View file

@ -104,9 +104,12 @@
} }
const response = await actions.updateOne(editTarget.value); const response = await actions.updateOne(editTarget.value);
// if name changed, redirect to new slug
if (response?.slug && book.value?.slug !== response?.slug) { if (response?.slug && book.value?.slug !== response?.slug) {
// if name changed, redirect to new slug
router.push(`/g/${route.value.params.groupSlug}/cookbooks/${response?.slug}`); router.push(`/g/${route.value.params.groupSlug}/cookbooks/${response?.slug}`);
} else {
// otherwise reload the page, since the recipe criteria changed
router.go(0);
} }
dialogStates.edit = false; dialogStates.edit = false;
editTarget.value = null; editTarget.value = null;

View file

@ -47,7 +47,6 @@
:recipe-id="recipe.id" :recipe-id="recipe.id"
:recipe-scale="recipeScale" :recipe-scale="recipeScale"
:use-items="{ :use-items="{
delete: false,
edit: false, edit: false,
download: loggedIn, download: loggedIn,
duplicate: loggedIn, duplicate: loggedIn,
@ -57,6 +56,7 @@
printPreferences: true, printPreferences: true,
share: loggedIn, share: loggedIn,
recipeActions: true, recipeActions: true,
delete: loggedIn,
}" }"
class="ml-1" class="ml-1"
@print="$emit('print')" @print="$emit('print')"

View file

@ -7,7 +7,7 @@
:elevation="hover ? 12 : 2" :elevation="hover ? 12 : 2"
:to="recipeRoute" :to="recipeRoute"
:min-height="imageHeight + 75" :min-height="imageHeight + 75"
@click="$emit('click')" @click.self="$emit('click')"
> >
<RecipeCardImage <RecipeCardImage
:icon-size="imageHeight" :icon-size="imageHeight"
@ -39,7 +39,7 @@
<RecipeRating class="pb-1" :value="rating" :recipe-id="recipeId" :slug="slug" :small="true" /> <RecipeRating class="pb-1" :value="rating" :recipe-id="recipeId" :slug="slug" :small="true" />
<v-spacer></v-spacer> <v-spacer></v-spacer>
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" /> <RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" v-on="$listeners" />
<!-- If we're not logged-in, no items display, so we hide this menu --> <!-- If we're not logged-in, no items display, so we hide this menu -->
<RecipeContextMenu <RecipeContextMenu

View file

@ -38,7 +38,7 @@
<SafeMarkdown :source="description" /> <SafeMarkdown :source="description" />
</v-list-item-subtitle> </v-list-item-subtitle>
<div class="d-flex flex-wrap justify-start ma-0"> <div class="d-flex flex-wrap justify-start ma-0">
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" /> <RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" v-on="$listeners" />
</div> </div>
<div class="d-flex flex-wrap justify-end align-center"> <div class="d-flex flex-wrap justify-end align-center">
<slot name="actions"> <slot name="actions">

View file

@ -82,6 +82,8 @@
:image="recipe.image" :image="recipe.image"
:tags="recipe.tags" :tags="recipe.tags"
:recipe-id="recipe.id" :recipe-id="recipe.id"
v-on="$listeners"
/> />
</v-lazy> </v-lazy>
</v-col> </v-col>
@ -105,6 +107,8 @@
:image="recipe.image" :image="recipe.image"
:tags="recipe.tags" :tags="recipe.tags"
:recipe-id="recipe.id" :recipe-id="recipe.id"
v-on="$listeners"
/> />
</v-lazy> </v-lazy>
</v-col> </v-col>
@ -215,16 +219,20 @@ export default defineComponent({
const router = useRouter(); const router = useRouter();
const queryFilter = computed(() => { const queryFilter = computed(() => {
const orderBy = props.query?.orderBy || preferences.value.orderBy; return props.query.queryFilter || null;
const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
if (props.query.queryFilter && orderByFilter) { // TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade)
return `(${props.query.queryFilter}) AND ${orderByFilter}`;
} else if (props.query.queryFilter) { // const orderBy = props.query?.orderBy || preferences.value.orderBy;
return props.query.queryFilter; // const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
} else {
return orderByFilter; // if (props.query.queryFilter && orderByFilter) {
} // return `(${props.query.queryFilter}) AND ${orderByFilter}`;
// } else if (props.query.queryFilter) {
// return props.query.queryFilter;
// } else {
// return orderByFilter;
// }
}); });
async function fetchRecipes(pageCount = 1) { async function fetchRecipes(pageCount = 1) {
@ -296,6 +304,7 @@ export default defineComponent({
}, useAsyncKey()); }, useAsyncKey());
}, 500); }, 500);
function sortRecipes(sortType: string) { function sortRecipes(sortType: string) {
if (state.sortLoading || loading.value) { if (state.sortLoading || loading.value) {
return; return;

View file

@ -9,7 +9,8 @@
color="accent" color="accent"
:small="small" :small="small"
dark dark
:to="`${baseRecipeRoute}?${urlPrefix}=${category.id}`"
@click.prevent="() => $emit('item-selected', category, urlPrefix)"
> >
{{ truncateText(category.name) }} {{ truncateText(category.name) }}
</v-chip> </v-chip>

View file

@ -276,7 +276,7 @@ export default defineComponent({
delete: { delete: {
title: i18n.tc("general.delete"), title: i18n.tc("general.delete"),
icon: $globals.icons.delete, icon: $globals.icons.delete,
color: "error", color: undefined,
event: "delete", event: "delete",
isPublic: false, isPublic: false,
}, },
@ -383,7 +383,10 @@ export default defineComponent({
} }
async function deleteRecipe() { async function deleteRecipe() {
await api.recipes.deleteOne(props.slug); const { data } = await api.recipes.deleteOne(props.slug);
if (data?.slug) {
router.push(`/g/${groupSlug.value}`);
}
context.emit("delete", props.slug); context.emit("delete", props.slug);
} }

View file

@ -23,13 +23,13 @@
<a :href="`/g/${groupSlug}/r/${item.slug}`" style="color: inherit; text-decoration: inherit; " @click="$emit('click')">{{ item.name }}</a> <a :href="`/g/${groupSlug}/r/${item.slug}`" style="color: inherit; text-decoration: inherit; " @click="$emit('click')">{{ item.name }}</a>
</template> </template>
<template #item.tags="{ item }"> <template #item.tags="{ item }">
<RecipeChip small :items="item.tags" :is-category="false" url-prefix="tags" /> <RecipeChip small :items="item.tags" :is-category="false" url-prefix="tags" @item-selected="filterItems" />
</template> </template>
<template #item.recipeCategory="{ item }"> <template #item.recipeCategory="{ item }">
<RecipeChip small :items="item.recipeCategory" /> <RecipeChip small :items="item.recipeCategory" @item-selected="filterItems" />
</template> </template>
<template #item.tools="{ item }"> <template #item.tools="{ item }">
<RecipeChip small :items="item.tools" url-prefix="tools" /> <RecipeChip small :items="item.tools" url-prefix="tools" @item-selected="filterItems" />
</template> </template>
<template #item.userId="{ item }"> <template #item.userId="{ item }">
<v-list-item class="justify-start"> <v-list-item class="justify-start">
@ -48,12 +48,13 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, onMounted, ref, useContext } from "@nuxtjs/composition-api"; import { computed, defineComponent, onMounted, ref, useContext, useRouter } from "@nuxtjs/composition-api";
import UserAvatar from "../User/UserAvatar.vue"; import UserAvatar from "../User/UserAvatar.vue";
import RecipeChip from "./RecipeChips.vue"; import RecipeChip from "./RecipeChips.vue";
import { Recipe } from "~/lib/api/types/recipe"; import { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { UserSummary } from "~/lib/api/types/user"; import { UserSummary } from "~/lib/api/types/user";
import { RecipeTag } from "~/lib/api/types/household";
const INPUT_EVENT = "input"; const INPUT_EVENT = "input";
@ -106,7 +107,7 @@ export default defineComponent({
setup(props, context) { setup(props, context) {
const { $auth, i18n } = useContext(); const { $auth, i18n } = useContext();
const groupSlug = $auth.user?.groupSlug; const groupSlug = $auth.user?.groupSlug;
const router = useRouter();
function setValue(value: Recipe[]) { function setValue(value: Recipe[]) {
context.emit(INPUT_EVENT, value); context.emit(INPUT_EVENT, value);
} }
@ -167,6 +168,13 @@ export default defineComponent({
} }
} }
function filterItems(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
if (!groupSlug || !item.id) {
return;
}
router.push(`/g/${groupSlug}?${itemType}=${item.id}`);
}
onMounted(() => { onMounted(() => {
refreshMembers(); refreshMembers();
}); });
@ -186,6 +194,7 @@ export default defineComponent({
formatDate, formatDate,
members, members,
getMember, getMember,
filterItems,
}; };
}, },

View file

@ -204,6 +204,10 @@ export default defineComponent({
shoppingListShowAllToggled: false, shoppingListShowAllToggled: false,
}); });
const userHousehold = computed(() => {
return $auth.user?.householdSlug || "";
});
const shoppingListChoices = computed(() => { const shoppingListChoices = computed(() => {
return props.shoppingLists.filter((list) => preferences.value.viewAllLists || list.userId === $auth.user?.id); return props.shoppingLists.filter((list) => preferences.value.viewAllLists || list.userId === $auth.user?.id);
}); });
@ -248,8 +252,9 @@ export default defineComponent({
} }
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => { const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
return { return {
checked: !ing.food?.onHand, checked: !householdsWithFood.includes(userHousehold.value),
ingredient: ing, ingredient: ing,
disableAmount: recipe.settings?.disableAmount || false, disableAmount: recipe.settings?.disableAmount || false,
} }
@ -276,7 +281,8 @@ export default defineComponent({
} }
// Store the on-hand ingredients for later // Store the on-hand ingredients for later
if (ing.ingredient.food?.onHand) { const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
if (householdsWithFood.includes(userHousehold.value)) {
onHandIngs.push(ing); onHandIngs.push(ing);
return sections; return sections;
} }

View file

@ -138,6 +138,7 @@
:title="$tc('general.recipes')" :title="$tc('general.recipes')"
:recipes="recipes" :recipes="recipes"
:query="passedQueryWithSeed" :query="passedQueryWithSeed"
@item-selected="filterItems"
@replaceRecipes="replaceRecipes" @replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes" @appendRecipes="appendRecipes"
/> />
@ -387,6 +388,19 @@ export default defineComponent({
} }
) )
function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) {
if (urlPrefix === "categories") {
const result = categories.store.value.filter((category) => (category.id as string).includes(item.id as string));
selectedCategories.value = result as NoUndefinedField<RecipeTag>[];
} else if (urlPrefix === "tags") {
const result = tags.store.value.filter((tag) => (tag.id as string).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
} else if (urlPrefix === "tools") {
const result = tools.store.value.filter((tool) => (tool.id ).includes(item.id || "" ));
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
}
}
async function hydrateSearch() { async function hydrateSearch() {
const query = router.currentRoute.query; const query = router.currentRoute.query;
if (query.auto?.length) { if (query.auto?.length) {
@ -592,6 +606,8 @@ export default defineComponent({
removeRecipe, removeRecipe,
replaceRecipes, replaceRecipes,
passedQueryWithSeed, passedQueryWithSeed,
filterItems,
}; };
}, },
head: {}, head: {},

View file

@ -96,7 +96,12 @@
<v-icon left> <v-icon left>
{{ $globals.icons.calendar }} {{ $globals.icons.calendar }}
</v-icon> </v-icon>
{{ $t('recipe.last-made-date', { date: value ? new Date(value).toLocaleDateString($i18n.locale) : $t("general.never") } ) }} <div v-if="lastMadeReady">
{{ $t('recipe.last-made-date', { date: lastMade ? new Date(lastMade).toLocaleDateString($i18n.locale) : $t("general.never") } ) }}
</div>
<div v-else>
<AppLoader tiny />
</div>
</v-chip> </v-chip>
</div> </div>
<div class="d-flex justify-center flex-wrap mt-1"> <div class="d-flex justify-center flex-wrap mt-1">
@ -110,7 +115,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api"; import { computed, defineComponent, onMounted, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/core"; import { whenever } from "@vueuse/core";
import { VForm } from "~/types/vuetify"; import { VForm } from "~/types/vuetify";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
@ -119,10 +124,6 @@ import { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
export default defineComponent({ export default defineComponent({
props: { props: {
value: {
type: String,
default: null,
},
recipe: { recipe: {
type: Object as () => Recipe, type: Object as () => Recipe,
required: true, required: true,
@ -146,6 +147,20 @@ export default defineComponent({
const newTimelineEventImagePreviewUrl = ref<string>(); const newTimelineEventImagePreviewUrl = ref<string>();
const newTimelineEventTimestamp = ref<string>(); const newTimelineEventTimestamp = ref<string>();
const lastMade = ref(props.recipe.lastMade);
const lastMadeReady = ref(false);
onMounted(async () => {
if (!$auth.user?.householdSlug) {
lastMade.value = props.recipe.lastMade;
} else {
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
lastMade.value = data?.lastMade;
}
lastMadeReady.value = true;
});
whenever( whenever(
() => madeThisDialog.value, () => madeThisDialog.value,
() => { () => {
@ -195,11 +210,9 @@ export default defineComponent({
const newEvent = eventResponse.data; const newEvent = eventResponse.data;
// we also update the recipe's last made value // we also update the recipe's last made value
if (!props.value || newTimelineEvent.value.timestamp > props.value) { if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
lastMade.value = newTimelineEvent.value.timestamp;
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp); await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
// update recipe in parent so the user can see it
context.emit("input", newTimelineEvent.value.timestamp);
} }
// update the image, if provided // update the image, if provided
@ -234,6 +247,8 @@ export default defineComponent({
newTimelineEventImage, newTimelineEventImage,
newTimelineEventImagePreviewUrl, newTimelineEventImagePreviewUrl,
newTimelineEventTimestamp, newTimelineEventTimestamp,
lastMade,
lastMadeReady,
createTimelineEvent, createTimelineEvent,
clearImage, clearImage,
uploadImage, uploadImage,

View file

@ -35,7 +35,7 @@
--> -->
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4"> <v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4">
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" /> <RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
<RecipePageOrganizers v-if="$vuetify.breakpoint.mdAndUp" :recipe="recipe" /> <RecipePageOrganizers v-if="$vuetify.breakpoint.mdAndUp" :recipe="recipe" @item-selected="chipClicked" />
</v-col> </v-col>
<v-divider v-if="$vuetify.breakpoint.mdAndUp && !isCookMode" class="my-divider" :vertical="true" /> <v-divider v-if="$vuetify.breakpoint.mdAndUp && !isCookMode" class="my-divider" :vertical="true" />
@ -166,7 +166,7 @@ import {
usePageUser, usePageUser,
} from "~/composables/recipe-page/shared-state"; } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe"; import { Recipe, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { useRouteQuery } from "~/composables/use-router"; import { useRouteQuery } from "~/composables/use-router";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { uuid4, deepCopy } from "~/composables/use-utils"; import { uuid4, deepCopy } from "~/composables/use-utils";
@ -329,6 +329,17 @@ export default defineComponent({
*/ */
const { user } = usePageUser(); const { user } = usePageUser();
/** =============================================================
* RecipeChip Clicked
*/
function chipClicked(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
if (!item.id) {
return;
}
router.push(`/g/${groupSlug.value}?${itemType}=${item.id}`);
}
return { return {
user, user,
isOwnGroup, isOwnGroup,
@ -350,7 +361,8 @@ export default defineComponent({
deleteRecipe, deleteRecipe,
addStep, addStep,
hasLinkedIngredients, hasLinkedIngredients,
notLinkedIngredients notLinkedIngredients,
chipClicked,
}; };
}, },
head: {}, head: {},

View file

@ -34,7 +34,7 @@
<UserAvatar :tooltip="false" size="40" :user-id="comment.userId" /> <UserAvatar :tooltip="false" size="40" :user-id="comment.userId" />
<v-card outlined class="flex-grow-1"> <v-card outlined class="flex-grow-1">
<v-card-text class="pa-3 pb-0"> <v-card-text class="pa-3 pb-0">
<p class="">{{ comment.user.username }} {{ $d(Date.parse(comment.createdAt), "medium") }}</p> <p class="">{{ comment.user.fullName }} {{ $d(Date.parse(comment.createdAt), "medium") }}</p>
<SafeMarkdown :source="comment.text" /> <SafeMarkdown :source="comment.text" />
</v-card-text> </v-card-text>
<v-card-actions class="justify-end mt-0 pt-0"> <v-card-actions class="justify-end mt-0 pt-0">

View file

@ -30,7 +30,6 @@
<v-col cols="12" class="d-flex flex-wrap justify-center"> <v-col cols="12" class="d-flex flex-wrap justify-center">
<RecipeLastMade <RecipeLastMade
v-if="isOwnGroup" v-if="isOwnGroup"
:value="recipe.lastMade"
:recipe="recipe" :recipe="recipe"
:class="true ? undefined : 'force-bottom'" :class="true ? undefined : 'force-bottom'"
/> />

View file

@ -10,7 +10,7 @@
<h2 class="mb-2 mt-4">{{ $t('tool.required-tools') }}</h2> <h2 class="mb-2 mt-4">{{ $t('tool.required-tools') }}</h2>
<v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense> <v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense>
<v-checkbox <v-checkbox
v-model="recipe.tools[index].onHand" v-model="recipeTools[index].onHand"
hide-details hide-details
class="pt-0 my-auto py-auto" class="pt-0 my-auto py-auto"
color="secondary" color="secondary"
@ -26,14 +26,18 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api"; import { computed, defineComponent } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state"; import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { useToolStore } from "~/composables/store"; import { useToolStore } from "~/composables/store";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe"; import { Recipe, RecipeTool } from "~/lib/api/types/recipe";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue"; import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
export default defineComponent({ export default defineComponent({
components: { components: {
RecipeIngredients, RecipeIngredients,
@ -59,9 +63,31 @@ export default defineComponent({
const { user } = usePageUser(); const { user } = usePageUser();
const { isEditMode } = usePageState(props.recipe.slug); const { isEditMode } = usePageState(props.recipe.slug);
const recipeTools = computed(() => {
if (!(user.householdSlug && toolStore)) {
return props.recipe.tools.map((tool) => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
} else {
return props.recipe.tools.map((tool) => {
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
return { ...tool, onHand } as RecipeToolWithOnHand;
});
}
})
function updateTool(index: number) { function updateTool(index: number) {
if (user.id && toolStore) { if (user.id && user.householdSlug && toolStore) {
toolStore.actions.updateOne(props.recipe.tools[index]); const tool = recipeTools.value[index];
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
if (!tool.householdsWithTool) {
tool.householdsWithTool = [user.householdSlug];
} else {
tool.householdsWithTool.push(user.householdSlug);
}
} else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
tool.householdsWithTool = tool.householdsWithTool.filter((household) => household !== user.householdSlug);
}
toolStore.actions.updateOne(tool);
} else { } else {
console.log("no user, skipping server update"); console.log("no user, skipping server update");
} }
@ -69,6 +95,7 @@ export default defineComponent({
return { return {
toolStore, toolStore,
recipeTools,
isEditMode, isEditMode,
updateTool, updateTool,
}; };

View file

@ -14,7 +14,7 @@
:show-add="true" :show-add="true"
selector-type="categories" selector-type="categories"
/> />
<RecipeChips v-else :items="recipe.recipeCategory" /> <RecipeChips v-else :items="recipe.recipeCategory" v-on="$listeners" />
</v-card-text> </v-card-text>
</v-card> </v-card>
@ -32,7 +32,7 @@
:show-add="true" :show-add="true"
selector-type="tags" selector-type="tags"
/> />
<RecipeChips v-else :items="recipe.tags" url-prefix="tags" /> <RecipeChips v-else :items="recipe.tags" url-prefix="tags" v-on="$listeners" />
</v-card-text> </v-card-text>
</v-card> </v-card>
@ -41,7 +41,7 @@
<v-card-title class="py-2"> {{ $t('tool.required-tools') }} </v-card-title> <v-card-title class="py-2"> {{ $t('tool.required-tools') }} </v-card-title>
<v-divider class="mx-2" /> <v-divider class="mx-2" />
<v-card-text class="pt-0"> <v-card-text class="pt-0">
<RecipeOrganizerSelector v-model="recipe.tools" selector-type="tools" /> <RecipeOrganizerSelector v-model="recipe.tools" selector-type="tools" v-on="$listeners" />
</v-card-text> </v-card-text>
</v-card> </v-card>
@ -82,6 +82,8 @@ export default defineComponent({
const { user } = usePageUser(); const { user } = usePageUser();
const { isEditForm } = usePageState(props.recipe.slug); const { isEditForm } = usePageState(props.recipe.slug);
return { return {
isEditForm, isEditForm,
user, user,

View file

@ -8,14 +8,14 @@
</v-icon> </v-icon>
<div v-if="large" class="text-small"> <div v-if="large" class="text-small">
<slot> <slot>
{{ small ? "" : waitingText }} {{ (small || tiny) ? "" : waitingText }}
</slot> </slot>
</div> </div>
</div> </div>
</v-progress-circular> </v-progress-circular>
<div v-if="!large" class="text-small"> <div v-if="!large" class="text-small">
<slot> <slot>
{{ small ? "" : waitingTextCalculated }} {{ (small || tiny) ? "" : waitingTextCalculated }}
</slot> </slot>
</div> </div>
</div> </div>
@ -31,6 +31,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
tiny: {
type: Boolean,
default: false,
},
small: { small: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -50,6 +54,13 @@ export default defineComponent({
}, },
setup(props) { setup(props) {
const size = computed(() => { const size = computed(() => {
if (props.tiny) {
return {
width: 2,
icon: 0,
size: 25,
};
}
if (props.small) { if (props.small) {
return { return {
width: 2, width: 2,

View file

@ -9,7 +9,6 @@ export const useTools = function (eager = true) {
id: "", id: "",
name: "", name: "",
slug: "", slug: "",
onHand: false,
}); });
const api = useUserApi(); const api = useUserApi();

View file

@ -13,7 +13,6 @@ export const useFoodData = function () {
name: "", name: "",
description: "", description: "",
labelId: undefined, labelId: undefined,
onHand: false,
}); });
} }

View file

@ -3,16 +3,21 @@ import { useData, useReadOnlyStore, useStore } from "../partials/use-store-facto
import { RecipeTool } from "~/lib/api/types/recipe"; import { RecipeTool } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api"; import { usePublicExploreApi, useUserApi } from "~/composables/api";
interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
const store: Ref<RecipeTool[]> = ref([]); const store: Ref<RecipeTool[]> = ref([]);
const loading = ref(false); const loading = ref(false);
const publicLoading = ref(false); const publicLoading = ref(false);
export const useToolData = function () { export const useToolData = function () {
return useData<RecipeTool>({ return useData<RecipeToolWithOnHand>({
id: "", id: "",
name: "", name: "",
slug: "", slug: "",
onHand: false, onHand: false,
householdsWithTool: [],
}); });
} }

View file

@ -250,60 +250,60 @@
"invite": "دعوة", "invite": "دعوة",
"looking-to-update-your-profile": "هل ترغب في تحديث ملفك الشخصي؟", "looking-to-update-your-profile": "هل ترغب في تحديث ملفك الشخصي؟",
"default-recipe-preferences-description": "هذه هي الإعدادات الافتراضية عند إنشاء وصفة جديدة في مجموعتك. يمكن تغيير هذه الوصفات الفردية في قائمة إعدادات الوصفات.", "default-recipe-preferences-description": "هذه هي الإعدادات الافتراضية عند إنشاء وصفة جديدة في مجموعتك. يمكن تغيير هذه الوصفات الفردية في قائمة إعدادات الوصفات.",
"default-recipe-preferences": "Default Recipe Preferences", "default-recipe-preferences": "تفضيلات الوصفة الافتراضية",
"group-preferences": "إعدادات المجموعة", "group-preferences": "إعدادات المجموعة",
"private-group": "مجموعة خاصة", "private-group": "مجموعة خاصة",
"private-group-description": "Setting your group to private will disable all public view options. This overrides any individual public view settings", "private-group-description": "سيؤدي تعيين مجموعتك إلى الخاص إلى تعطيل جميع خيارات العرض العام. وهذا يلغي أي إعدادات عرض عام فردية",
"enable-public-access": "Enable Public Access", "enable-public-access": "تمكين الوصول للعموم",
"enable-public-access-description": "Make group recipes public by default, and allow visitors to view recipes without logging-in", "enable-public-access-description": "جعل وصفات المجموعة عامة بشكل افتراضي، والسماح للزوار بعرض الوصفات دون تسجيل الدخول",
"allow-users-outside-of-your-group-to-see-your-recipes": "السماح للمستخدمين خارج مجموعتك لمشاهدة وصفاتك", "allow-users-outside-of-your-group-to-see-your-recipes": "السماح للمستخدمين خارج مجموعتك لمشاهدة وصفاتك",
"allow-users-outside-of-your-group-to-see-your-recipes-description": "When enabled you can use a public share link to share specific recipes without authorizing the user. When disabled, you can only share recipes with users who are in your group or with a pre-generated private link", "allow-users-outside-of-your-group-to-see-your-recipes-description": "عند التمكين يمكنك استخدام رابط المشاركة العامة لمشاركة وصفات محددة دون تفويض المستخدم. عند التعطيل، يمكنك مشاركة الوصفات فقط مع المستخدمين الموجودين في مجموعتك أو مع رابط خاص تم إنشاؤه مسبقاً",
"show-nutrition-information": "عرض معلومات التغذية", "show-nutrition-information": "عرض معلومات التغذية",
"show-nutrition-information-description": "When enabled the nutrition information will be shown on the recipe if available. If there is no nutrition information available, the nutrition information will not be shown", "show-nutrition-information-description": "عندما يتم تمكين المعلومات الغذائية ستظهر على الوصفة إذا كانت متاحة. وفي حالة عدم توافر معلومات عن التغذية، لن تظهر المعلومات المتعلقة بالتغذية",
"show-recipe-assets": "Show recipe assets", "show-recipe-assets": "إظهار أصول الوصفة",
"show-recipe-assets-description": "When enabled the recipe assets will be shown on the recipe if available", "show-recipe-assets-description": "عند تمكين الوصفة، سيتم عرض أصول الوصفة على الوصفة إذا كانت متوفرة",
"default-to-landscape-view": "Default to landscape view", "default-to-landscape-view": "الافتراضي للعرض الأفقي",
"default-to-landscape-view-description": "When enabled the recipe header section will be shown in landscape view", "default-to-landscape-view-description": "عند تمكين قسم رأس الوصفة سوف يظهر في العرض الأفقي",
"disable-users-from-commenting-on-recipes": "إيقاف المستخدمين من التعليق على الوصفات", "disable-users-from-commenting-on-recipes": "إيقاف المستخدمين من التعليق على الوصفات",
"disable-users-from-commenting-on-recipes-description": "Hides the comment section on the recipe page and disables commenting", "disable-users-from-commenting-on-recipes-description": "يخفي قسم التعليق على صفحة الوصفة ويعطل التعليق",
"disable-organizing-recipe-ingredients-by-units-and-food": "Disable organizing recipe ingredients by units and food", "disable-organizing-recipe-ingredients-by-units-and-food": "تعطيل تنظيم عناصر الوصفة حسب الوحدات والطعام",
"disable-organizing-recipe-ingredients-by-units-and-food-description": "Hides the Food, Unit, and Amount fields for ingredients and treats ingredients as plain text fields", "disable-organizing-recipe-ingredients-by-units-and-food-description": "يخفي حقول الطعام والوحدة والكمية للمكونات ويعامل المكونات كحقول نصية عادية",
"general-preferences": "General Preferences", "general-preferences": "الإعدادات العامة",
"group-recipe-preferences": "Group Recipe Preferences", "group-recipe-preferences": "تفضيلات الوصفة للمجموعة",
"report": "تقرير", "report": "تقرير",
"report-with-id": "Report ID: {id}", "report-with-id": "معرف التقرير: {id}",
"group-management": "Group Management", "group-management": "إدارة المجموعة",
"admin-group-management": "Admin Group Management", "admin-group-management": "إدارة مجموعة المشرف",
"admin-group-management-text": "Changes to this group will be reflected immediately.", "admin-group-management-text": "التغييرات التي ستطرأ على هذه المجموعة ستنعكس على الفور.",
"group-id-value": "Group Id: {0}", "group-id-value": "معرف المجموعة: {0}",
"total-households": "Total Households", "total-households": "مجموع المنزل",
"you-must-select-a-group-before-selecting-a-household": "You must select a group before selecting a household" "you-must-select-a-group-before-selecting-a-household": "يجب عليك تحديد مجموعة قبل تحديد المنزل"
}, },
"household": { "household": {
"household": "Household", "household": "المنزل",
"households": "Households", "households": "المنازل",
"user-household": "User Household", "user-household": "منزل المستخدم",
"create-household": "Create Household", "create-household": "إنشاء منزل",
"household-name": "Household Name", "household-name": "اسم المنزل",
"household-group": "Household Group", "household-group": "مجموعة المنزل",
"household-management": "Household Management", "household-management": "إدارة المنزل",
"manage-households": "Manage Households", "manage-households": "إدارة المنازل",
"admin-household-management": "Admin Household Management", "admin-household-management": "إدارة مشرف المنزل",
"admin-household-management-text": "Changes to this household will be reflected immediately.", "admin-household-management-text": "التغييرات التي ستطرأ على هذا المنزل ستنعكس على الفور.",
"household-id-value": "Household Id: {0}", "household-id-value": "معرف المنزل: {0}",
"private-household": "Private Household", "private-household": "منزل خاص",
"private-household-description": "Setting your household to private will disable all public view options. This overrides any individual public view settings", "private-household-description": "سيؤدي تعيين المنزل إلى خاص إلى تعطيل جميع خيارات العرض العام. وهذا يلغي أي إعدادات عرض عام فردية",
"lock-recipe-edits-from-other-households": "Lock recipe edits from other households", "lock-recipe-edits-from-other-households": "إقفال تحرير الوصفة من المنازل الأخرى",
"lock-recipe-edits-from-other-households-description": "When enabled only users in your household can edit recipes created by your household", "lock-recipe-edits-from-other-households-description": "عند التمكين, المستخدمين فقط في أسرتك المعيشية يمكنهم تعديل الوصفات التي أنشأتها أسرتك",
"household-recipe-preferences": "Household Recipe Preferences", "household-recipe-preferences": "تفضيلات الوصفة المنزلية",
"default-recipe-preferences-description": "These are the default settings when a new recipe is created in your household. These can be changed for individual recipes in the recipe settings menu.", "default-recipe-preferences-description": "هذه هي الإعدادات الافتراضية عند إنشاء وصفة جديدة في منزلك. يمكن تغيير الوصفات الفردية في قائمة إعدادات الوصفة.",
"allow-users-outside-of-your-household-to-see-your-recipes": "Allow users outside of your household to see your recipes", "allow-users-outside-of-your-household-to-see-your-recipes": "السماح للمستخدمين خارج منزلك بمشاهدة وصفاتك",
"allow-users-outside-of-your-household-to-see-your-recipes-description": "When enabled you can use a public share link to share specific recipes without authorizing the user. When disabled, you can only share recipes with users who are in your household or with a pre-generated private link", "allow-users-outside-of-your-household-to-see-your-recipes-description": "عند التمكين يمكنك استخدام رابط المشاركة العامة لمشاركة وصفات محددة دون تفويض المستخدم. عند التعطيل، يمكنك مشاركة الوصفات فقط مع المستخدمين الموجودين في منزلك أو مع رابط خاص تم إنشاؤه مسبقاً",
"household-preferences": "Household Preferences" "household-preferences": "تفضيلات المنزل"
}, },
"meal-plan": { "meal-plan": {
"create-a-new-meal-plan": "إنشاء خطة وجبة جديدة", "create-a-new-meal-plan": "إنشاء خطة وجبة جديدة",
"update-this-meal-plan": "Update this Meal Plan", "update-this-meal-plan": "تحديث خِطَّة الوجبة الغذائية هذه",
"dinner-this-week": "العشاء لهذا الأسبوع", "dinner-this-week": "العشاء لهذا الأسبوع",
"dinner-today": "العشاء اليوم", "dinner-today": "العشاء اليوم",
"dinner-tonight": "العشاء الليلة", "dinner-tonight": "العشاء الليلة",
@ -321,95 +321,95 @@
"mealplan-settings": "اعدادات خطة الوجبات", "mealplan-settings": "اعدادات خطة الوجبات",
"mealplan-update-failed": "فشل تحديث خطة الوجبات", "mealplan-update-failed": "فشل تحديث خطة الوجبات",
"mealplan-updated": "تم تحديث خطة الوجبات", "mealplan-updated": "تم تحديث خطة الوجبات",
"mealplan-households-description": "If no household is selected, recipes can be added from any household", "mealplan-households-description": "إذا لم يتم اختيار منزل، يمكن إضافة وصفات من أي منزل",
"any-category": "Any Category", "any-category": "أي فئة",
"any-tag": "Any Tag", "any-tag": "أي وسم",
"any-household": "Any Household", "any-household": "أي منزل",
"no-meal-plan-defined-yet": "لم يتم تحديد خطة بعد", "no-meal-plan-defined-yet": "لم يتم تحديد خطة بعد",
"no-meal-planned-for-today": "لم يتم تخطيط وجبة لهذا اليوم", "no-meal-planned-for-today": "لم يتم تخطيط وجبة لهذا اليوم",
"numberOfDays-hint": "Number of days on page load", "numberOfDays-hint": "عدد الأيام عند تحميل الصفحة",
"numberOfDays-label": "Default Days", "numberOfDays-label": "الأيام الافتراضية",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "فقط الوجبات التي تحتوي على التصنيفات التالية سوف تستخدم لإنشاء خطتك", "only-recipes-with-these-categories-will-be-used-in-meal-plans": "فقط الوجبات التي تحتوي على التصنيفات التالية سوف تستخدم لإنشاء خطتك",
"planner": "المخطط", "planner": "المخطط",
"quick-week": "Quick Week", "quick-week": "أسبوع سريع",
"side": "وجبة جانبية", "side": "وجبة جانبية",
"sides": "الوجبات الجانبية", "sides": "الوجبات الجانبية",
"start-date": "تاريخ البدء", "start-date": "تاريخ البدء",
"rule-day": "Rule Day", "rule-day": "يوم القاعدة",
"meal-type": "نوع الوجبة", "meal-type": "نوع الوجبة",
"breakfast": "الإفطار", "breakfast": "الإفطار",
"lunch": "الغداء", "lunch": "الغداء",
"dinner": "العشاء", "dinner": "العشاء",
"type-any": "أي", "type-any": "أي",
"day-any": "أي", "day-any": "أي",
"editor": "Editor", "editor": "المحرر",
"meal-recipe": "وصفة الوجبة", "meal-recipe": "وصفة الوجبة",
"meal-title": "عنوان الوجبة", "meal-title": "عنوان الوجبة",
"meal-note": "ملاحظة الوجبة", "meal-note": "ملاحظة الوجبة",
"note-only": "ملاحظة فقط", "note-only": "ملاحظة فقط",
"random-meal": "وجبة عشوائية", "random-meal": "وجبة عشوائية",
"random-dinner": "عشاء عشوائي", "random-dinner": "عشاء عشوائي",
"random-side": "Random Side", "random-side": "جانب عشوائي",
"this-rule-will-apply": "This rule will apply {dayCriteria} {mealTypeCriteria}.", "this-rule-will-apply": "هذه القاعدة سوف تطبق على {dayCriteria} {mealTypeCriteria}.",
"to-all-days": "إلى جميع الأيام", "to-all-days": "إلى جميع الأيام",
"on-days": "on {0}s", "on-days": "على أيام {0}",
"for-all-meal-types": "لجميع أنواع الوجبات", "for-all-meal-types": "لجميع أنواع الوجبات",
"for-type-meal-types": "for {0} meal types", "for-type-meal-types": "لأنواع الوجبات {0}",
"meal-plan-rules": "Meal Plan Rules", "meal-plan-rules": "قواعد خِطَّة وجبة الطعام",
"new-rule": "قاعدة جديدة", "new-rule": "قاعدة جديدة",
"meal-plan-rules-description": "You can create rules for auto selecting recipes for your meal plans. These rules are used by the server to determine the random pool of recipes to select from when creating meal plans. Note that if rules have the same day/type constraints then the rule filters will be merged. In practice, it's unnecessary to create duplicate rules, but it's possible to do so.", "meal-plan-rules-description": "يمكنك إنشاء قواعد لاختيار الوصفات التلقائية لخطط وجبتك الغذائية. وتستخدم هذه القواعد من قبل الخادم لتحديد مجموعة عشوائية من الوصفات التي يتم اختيارها من خلال إنشاء خطط الوجبات. لاحظ أنه إذا كانت القواعد تحتوي على نفس قيود اليوم/النوع فسيتم دمج عوامل تصفية القاعدة. من الناحية العملية، ليس من الضروري إنشاء قواعد مكررة، ولكن من الممكن فعل ذلك.",
"new-rule-description": "When creating a new rule for a meal plan you can restrict the rule to be applicable for a specific day of the week and/or a specific type of meal. To apply a rule to all days or all meal types you can set the rule to \"Any\" which will apply it to all the possible values for the day and/or meal type.", "new-rule-description": "عند إنشاء قاعدة جديدة لخطة وجبة غذائية، يمكنك تقييد القاعدة لتكون قابلة للتطبيق ليوم محدد من الأسبوع و/أو نوع محدد من الوجبات. لتطبيق قاعدة على جميع الأيام أو جميع أنواع الوجبات الغذائية يمكنك تعيين القاعدة إلى \"أي كان\" التي ستطبقها على جميع القيم الممكنة لليوم و/أو نوع الوجبة.",
"recipe-rules": "قواعد الوصفات", "recipe-rules": "قواعد الوصفات",
"applies-to-all-days": "ينطبق على جميع الأيام", "applies-to-all-days": "ينطبق على جميع الأيام",
"applies-on-days": "Applies on {0}s", "applies-on-days": "يطبق على أيام {0}",
"meal-plan-settings": "Meal Plan Settings" "meal-plan-settings": "إعدادات خِطَّة الوجبات الغذائية"
}, },
"migration": { "migration": {
"migration-data-removed": "Migration data removed", "migration-data-removed": "حذف بيانات الهجرة",
"new-migration": "New Migration", "new-migration": "هجرة جديدة",
"no-file-selected": "لم يتمّ اختيار أيّ ملفّ", "no-file-selected": "لم يتمّ اختيار أيّ ملفّ",
"no-migration-data-available": "No Migration Data Available", "no-migration-data-available": "لا توجد بيانات هجرة متوفرة",
"previous-migrations": "Previous Migrations", "previous-migrations": "الهجرة السابقة",
"recipe-migration": "نقل الوصفة", "recipe-migration": "نقل الوصفة",
"chowdown": { "chowdown": {
"description": "Migrate data from Chowdown", "description": "نقل البيانات من \"Chowdown\"",
"description-long": "Mealie natively supports the chowdown repository format. Download the code repository as a .zip file and upload it below.", "description-long": "ميلي يدعم بشكل محلي تنسيق مستودع طعام. يجب تنزيل مستودع التعليمات البرمجية CODE REPOSITORY كملف مضغوط ZIP وتحميله أدناه.",
"title": "Chowdown" "title": "\"Chowdown\""
}, },
"nextcloud": { "nextcloud": {
"description": "Migrate data from a Nextcloud Cookbook instance", "description": "نقل البيانات من نموذج كتاب طبخ NEXTCLOUD",
"description-long": "Nextcloud recipes can be imported from a zip file that contains the data stored in Nextcloud. See the example folder structure below to ensure your recipes are able to be imported.", "description-long": "يمكن استيراد الوصفات السحابية من مِلَفّ مضغوط ZIP يحتوي على البيانات المخزنة في Nextcloud. راجع بنية مجلد المثال أدناه للتأكد من أن وصفاتك قابلة للاستيراد.",
"title": "Nextcloud Cookbook" "title": "كتاب طبخ <Nextcloud>"
}, },
"copymethat": { "copymethat": {
"description-long": "Mealie can import recipes from Copy Me That. Export your recipes in HTML format, then upload the .zip below.", "description-long": "يمكن لميلي استيراد الوصفات من نسخ لي. يجب تصدير وصفاتك بتنسيق HTML، ثم تحميل ZIP أدناه.",
"title": "Copy Me That Recipe Manager" "title": "انسخ لي مدير الوصفة"
}, },
"paprika": { "paprika": {
"description-long": "Mealie can import recipes from the Paprika application. Export your recipes from paprika, rename the export extension to .zip and upload it below.", "description-long": "يمكن لميلي استيراد الوصفات من تطبيق PAPRIKA. يجب تصدير وصفاتك من PAPRIKA، وإعادة تسمية امتداد التصدير إلى .ZIP وتحميله أدناه.",
"title": "Paprika Recipe Manager" "title": "مدير وصفة بابريكا"
}, },
"mealie-pre-v1": { "mealie-pre-v1": {
"description-long": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.", "description-long": "يمكن لميلي استيراد الوصفات من تطبيق ميلي من إصدار قبل 1.0. يجب تصدير وصفاتك من نموذجك القديم، وتحميل المِلَفّ المضغوط أدناه. لاحظ أنه يمكن استيراد الوصفات فقط من التصدير.",
"title": "Mealie Pre v1.0" "title": "ميلي إصدار قبل 1.0"
}, },
"tandoor": { "tandoor": {
"description-long": "Mealie can import recipes from Tandoor. Export your data in the \"Default\" format, then upload the .zip below.", "description-long": "يمكن لميلي استيراد الوصفات من تندور. يجب تصدير بياناتك بالتنسيق \"الافتراضي\"، ثم يجب تحميل المِلَفّ المضغوط أدناه.",
"title": "Tandoor Recipes" "title": "وصفات تاندور"
}, },
"recipe-data-migrations": "Recipe Data Migrations", "recipe-data-migrations": "وصفة 2",
"recipe-data-migrations-explanation": "Recipes can be migrated from another supported application to Mealie. This is a great way to get started with Mealie.", "recipe-data-migrations-explanation": "يمكن نقل الوصفات من تطبيق آخر مدعوم إلى ميلي. هذه طريقة رائعة للبدء مع ميلي.",
"coming-from-another-application-or-an-even-older-version-of-mealie": "Coming from another application or an even older version of Mealie? Check out migrations and see if your data can be imported.", "coming-from-another-application-or-an-even-older-version-of-mealie": "هل تأتي من تطبيق آخر أو حتى إصدار قديم من ميلي؟ يجب التحقق من عمليات الترحيل لمعرفة ما إذا كان يمكن استيراد بياناتك.",
"choose-migration-type": "Choose Migration Type", "choose-migration-type": "اختر نوع الترحيل",
"tag-all-recipes": "Tag all recipes with {tag-name} tag", "tag-all-recipes": "وسم جميع الوصفات باستخدام علامة {tag-name}",
"nextcloud-text": "Nextcloud recipes can be imported from a zip file that contains the data stored in Nextcloud. See the example folder structure below to ensure your recipes are able to be imported.", "nextcloud-text": "يمكن استيراد الوصفات السحابية من مِلَفّ مضغوط Zip يحتوي على البيانات المخزنة في Nextcloud. راجع بنية مجلد المثال أدناه للتأكد من أن وصفاتك قابلة للاستيراد.",
"chowdown-text": "Mealie natively supports the chowdown repository format. Download the code repository as a .zip file and upload it below.", "chowdown-text": "Mealie natively supports the chowdown repository format. Download the code repository as a .zip file and upload it below.",
"recipe-1": "Recipe 1", "recipe-1": "وصفة 1",
"recipe-2": "Recipe 2", "recipe-2": "وصفة 2",
"paprika-text": "Mealie can import recipes from the Paprika application. Export your recipes from paprika, rename the export extension to .zip and upload it below.", "paprika-text": "Mealie can import recipes from the Paprika application. Export your recipes from paprika, rename the export extension to .zip and upload it below.",
"mealie-text": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.", "mealie-text": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.",
"plantoeat": { "plantoeat": {
"title": "Plan to Eat", "title": "خِطَّة تناول الطعام",
"description-long": "Mealie can import recipies from Plan to Eat." "description-long": "Mealie can import recipies from Plan to Eat."
}, },
"myrecipebox": { "myrecipebox": {
@ -417,44 +417,44 @@
"description-long": "Mealie can import recipes from My Recipe Box. Export your recipes in CSV format, then upload the .csv file below." "description-long": "Mealie can import recipes from My Recipe Box. Export your recipes in CSV format, then upload the .csv file below."
}, },
"recipekeeper": { "recipekeeper": {
"title": "Recipe Keeper", "title": "مدير الوصفة",
"description-long": "Mealie can import recipes from Recipe Keeper. Export your recipes in zip format, then upload the .zip file below." "description-long": "Mealie can import recipes from Recipe Keeper. Export your recipes in zip format, then upload the .zip file below."
} }
}, },
"new-recipe": { "new-recipe": {
"bulk-add": "Bulk Add", "bulk-add": "إضافة مجموعة",
"error-details": "Only websites containing ld+json or microdata can be imported by Mealie. Most major recipe websites support this data structure. If your site cannot be imported but there is json data in the log, please submit a github issue with the URL and data.", "error-details": "Only websites containing ld+json or microdata can be imported by Mealie. Most major recipe websites support this data structure. If your site cannot be imported but there is json data in the log, please submit a github issue with the URL and data.",
"error-title": "Looks Like We Couldn't Find Anything", "error-title": "Looks Like We Couldn't Find Anything",
"from-url": "Import a Recipe", "from-url": "استيراد وصفة",
"github-issues": "مشاكل GitHub", "github-issues": "مشاكل GitHub",
"google-ld-json-info": "معرف Google + معلومات json", "google-ld-json-info": "معرف Google + معلومات json",
"must-be-a-valid-url": "يجب أن يكون عنوان URL صالحًا", "must-be-a-valid-url": "يجب أن يكون عنوان URL صالحًا",
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Paste in your recipe data. Each line will be treated as an item in a list", "paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Paste in your recipe data. Each line will be treated as an item in a list",
"recipe-markup-specification": "Recipe Markup Specification", "recipe-markup-specification": "Recipe Markup Specification",
"recipe-url": "Recipe URL", "recipe-url": "رابط الوصفة",
"recipe-html-or-json": "Recipe HTML or JSON", "recipe-html-or-json": "وصفة HTML أو JSON",
"upload-a-recipe": "Upload a Recipe", "upload-a-recipe": "تحميل وصفة",
"upload-individual-zip-file": "Upload an individual .zip file exported from another Mealie instance.", "upload-individual-zip-file": "تحميل مِلَفّ zip فردي تم تصديره من مثيل Malie آخر.",
"url-form-hint": "Copy and paste a link from your favorite recipe website", "url-form-hint": "نسخ ولصق رابط من موقعك المفضل للوصفة",
"view-scraped-data": "View Scraped Data", "view-scraped-data": "عرض البيانات المكشوفة",
"trim-whitespace-description": "Trim leading and trailing whitespace as well as blank lines", "trim-whitespace-description": "قص المسافات البيضاء البادئة واللاحقة وكذلك الأسطر الفارغة",
"trim-prefix-description": "Trim first character from each line", "trim-prefix-description": "قص الحرف الأول من كل سطر",
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns", "split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns",
"import-by-url": "Import a recipe by URL", "import-by-url": "استيراد وصفة عن طريق عنوان URL",
"create-manually": "Create a recipe manually", "create-manually": "إنشاء وصفة يدوياً",
"make-recipe-image": "Make this the recipe image" "make-recipe-image": "اجعل هذه صورة الوصفة"
}, },
"page": { "page": {
"404-page-not-found": "404 Page not found", "404-page-not-found": "404: لم يتم العثور على الصفحة",
"all-recipes": "All Recipes", "all-recipes": "جميع الوصفات",
"new-page-created": "New page created", "new-page-created": "تم إنشاء الصفحة الجديدة",
"page": "الصفحة", "page": "الصفحة",
"page-creation-failed": "Page creation failed", "page-creation-failed": "فشل إنشاء الصفحة",
"page-deleted": "تم حذف الصفحة", "page-deleted": "تم حذف الصفحة",
"page-deletion-failed": "حذف الصفحة فشل", "page-deletion-failed": "حذف الصفحة فشل",
"page-update-failed": "تحديث الصفحة فشل", "page-update-failed": "تحديث الصفحة فشل",
"page-updated": "تم تحديث صفحة", "page-updated": "تم تحديث صفحة",
"pages-update-failed": "Pages update failed", "pages-update-failed": "فشل تحديث الصفحات",
"pages-updated": "Pages updated", "pages-updated": "Pages updated",
"404-not-found": "لم يتم العثور على الصفحة. خطأ 404", "404-not-found": "لم يتم العثور على الصفحة. خطأ 404",
"an-error-occurred": "حصل خطأ ما" "an-error-occurred": "حصل خطأ ما"
@ -500,42 +500,42 @@
"object-value": "Object Value", "object-value": "Object Value",
"original-url": "Original URL", "original-url": "Original URL",
"perform-time": "Cook Time", "perform-time": "Cook Time",
"prep-time": "Prep Time", "prep-time": "وقت التحضير",
"protein-content": "Protein", "protein-content": "البروتين",
"public-recipe": "Public Recipe", "public-recipe": "وصفة عامة",
"recipe-created": "Recipe created", "recipe-created": "تم إنشاء الوصفة",
"recipe-creation-failed": "Recipe creation failed", "recipe-creation-failed": "فشل إنشاء الوصفة",
"recipe-deleted": "Recipe deleted", "recipe-deleted": "تم حذف الوصفة",
"recipe-image": "Recipe Image", "recipe-image": "صورة الوصفة",
"recipe-image-updated": "Recipe image updated", "recipe-image-updated": "تم تحديث صورة الوصفة",
"recipe-name": "Recipe Name", "recipe-name": "اسم الوصفة",
"recipe-settings": "Recipe Settings", "recipe-settings": "إعدادات الوصفة",
"recipe-update-failed": "Recipe update failed", "recipe-update-failed": "فشل تحديث الوصفة",
"recipe-updated": "Recipe updated", "recipe-updated": "تم تحديث الوصفة",
"remove-from-favorites": "Remove from Favorites", "remove-from-favorites": "إزالة من المفضلات",
"remove-section": "Remove Section", "remove-section": "إزالة القسم",
"saturated-fat-content": "Saturated fat", "saturated-fat-content": "الدهون المشبعة",
"save-recipe-before-use": "Save recipe before use", "save-recipe-before-use": "حفظ الوصفة قبل الاستخدام",
"section-title": "Section Title", "section-title": "عنوان القسم",
"servings": "Servings", "servings": "حصص الطعام",
"serves-amount": "Serves {amount}", "serves-amount": "{amount} حصص",
"share-recipe-message": "I wanted to share my {0} recipe with you.", "share-recipe-message": "أردت أن أشارككم وصفة {0} الخاصة بي.",
"show-nutrition-values": "Show Nutrition Values", "show-nutrition-values": "Show Nutrition Values",
"sodium-content": "Sodium", "sodium-content": "صوديوم",
"step-index": "Step: {step}", "step-index": "الخطوة: {step}",
"sugar-content": "Sugar", "sugar-content": "سكر",
"title": "Title", "title": "العنوان",
"total-time": "Total Time", "total-time": "الوقت الإجمالي",
"trans-fat-content": "Trans-fat", "trans-fat-content": "الدهون المتحولة",
"unable-to-delete-recipe": "Unable to Delete Recipe", "unable-to-delete-recipe": "تعذر حذف الوصفة",
"unsaturated-fat-content": "Unsaturated fat", "unsaturated-fat-content": "دهون غير مشبعة",
"no-recipe": "No Recipe", "no-recipe": "لا يوجد وصفة",
"locked-by-owner": "Locked by Owner", "locked-by-owner": "مقفلة من قبل المالك",
"join-the-conversation": "Join the Conversation", "join-the-conversation": "انضم للمحادثة",
"add-recipe-to-mealplan": "Add Recipe to Mealplan", "add-recipe-to-mealplan": "إضافة الوصفة إلى خِطَّة الوجبة",
"entry-type": "Entry Type", "entry-type": "نوع الإدخال",
"date-format-hint": "MM/DD/YYYY format", "date-format-hint": "صيغة MM/DD/YYYYY",
"date-format-hint-yyyy-mm-dd": "YYYY-MM-DD format", "date-format-hint-yyyy-mm-dd": "صيغة YYY-MM-DD",
"add-to-list": "Add to List", "add-to-list": "Add to List",
"add-to-plan": "Add to Plan", "add-to-plan": "Add to Plan",
"add-to-timeline": "Add to Timeline", "add-to-timeline": "Add to Timeline",

View file

@ -259,7 +259,7 @@
"allow-users-outside-of-your-group-to-see-your-recipes": "Povolit uživatelům mimo vaši skupinu vidět vaše recepty", "allow-users-outside-of-your-group-to-see-your-recipes": "Povolit uživatelům mimo vaši skupinu vidět vaše recepty",
"allow-users-outside-of-your-group-to-see-your-recipes-description": "Pokud je tato možnost povolena, můžete použít veřejný odkaz pro sdílení konkrétních receptů bez autorizace uživatele. Pokud je tato možnost vypnutá, můžete sdílet recepty pouze s uživateli, kteří jsou ve vaší skupině, nebo s předem vygenerovaným soukromým odkazem", "allow-users-outside-of-your-group-to-see-your-recipes-description": "Pokud je tato možnost povolena, můžete použít veřejný odkaz pro sdílení konkrétních receptů bez autorizace uživatele. Pokud je tato možnost vypnutá, můžete sdílet recepty pouze s uživateli, kteří jsou ve vaší skupině, nebo s předem vygenerovaným soukromým odkazem",
"show-nutrition-information": "Zobrazit nutriční informace", "show-nutrition-information": "Zobrazit nutriční informace",
"show-nutrition-information-description": "When enabled the nutrition information will be shown on the recipe if available. If there is no nutrition information available, the nutrition information will not be shown", "show-nutrition-information-description": "Pokud je povoleno, informace o výživě se zobrazí na receptu, pokud je k dispozici. Nejsou-li k dispozici údaje o výživové hodnotě, nebudou zobrazeny údaje o výživové hodnotě",
"show-recipe-assets": "Zobrazit položky receptu", "show-recipe-assets": "Zobrazit položky receptu",
"show-recipe-assets-description": "Pokud je tato možnost povolena, zobrazí se u receptu zdroje, pokud jsou k dispozici", "show-recipe-assets-description": "Pokud je tato možnost povolena, zobrazí se u receptu zdroje, pokud jsou k dispozici",
"default-to-landscape-view": "Výchozí zobrazení na šířku", "default-to-landscape-view": "Výchozí zobrazení na šířku",
@ -270,19 +270,19 @@
"disable-organizing-recipe-ingredients-by-units-and-food-description": "Skryje pole Potravina, Jednotka a Množství pro ingredience a považuje ingredience za textová pole", "disable-organizing-recipe-ingredients-by-units-and-food-description": "Skryje pole Potravina, Jednotka a Množství pro ingredience a považuje ingredience za textová pole",
"general-preferences": "Všeobecné předvolby", "general-preferences": "Všeobecné předvolby",
"group-recipe-preferences": "Preference receptů pro skupinu", "group-recipe-preferences": "Preference receptů pro skupinu",
"report": "Report", "report": "Nahlásit",
"report-with-id": "Report ID: {id}", "report-with-id": "ID hlášení: {id}",
"group-management": "Správa skupin", "group-management": "Správa skupin",
"admin-group-management": "Administrátorská správa skupiny", "admin-group-management": "Administrátorská správa skupiny",
"admin-group-management-text": "Změny v této skupině budou okamžitě zohledněny.", "admin-group-management-text": "Změny v této skupině budou okamžitě zohledněny.",
"group-id-value": "ID skupiny: {0}", "group-id-value": "ID skupiny: {0}",
"total-households": "Celkem domácností", "total-households": "Celkem domácností",
"you-must-select-a-group-before-selecting-a-household": "You must select a group before selecting a household" "you-must-select-a-group-before-selecting-a-household": "Před výběrem domácnosti musíte vybrat skupinu"
}, },
"household": { "household": {
"household": "Domácnost", "household": "Domácnost",
"households": "Domácnosti", "households": "Domácnosti",
"user-household": "User Household", "user-household": "Uživatelova domácnost",
"create-household": "Vytvořit domácnost", "create-household": "Vytvořit domácnost",
"household-name": "Název domácnosti", "household-name": "Název domácnosti",
"household-group": "Skupina domácnosti", "household-group": "Skupina domácnosti",
@ -349,7 +349,7 @@
"note-only": "Pouze poznámka", "note-only": "Pouze poznámka",
"random-meal": "Náhodné jídlo", "random-meal": "Náhodné jídlo",
"random-dinner": "Náhodná večeře", "random-dinner": "Náhodná večeře",
"random-side": "Random Side", "random-side": "Náhodná příloha",
"this-rule-will-apply": "Toto pravidlo se použije {dayCriteria} {mealTypeCriteria}.", "this-rule-will-apply": "Toto pravidlo se použije {dayCriteria} {mealTypeCriteria}.",
"to-all-days": "na všechny dny", "to-all-days": "na všechny dny",
"on-days": "on {0}s", "on-days": "on {0}s",
@ -357,8 +357,8 @@
"for-type-meal-types": "pro {0} druhy jídel", "for-type-meal-types": "pro {0} druhy jídel",
"meal-plan-rules": "Pravidla tvůrce jídelníčků", "meal-plan-rules": "Pravidla tvůrce jídelníčků",
"new-rule": "Nové pravidlo", "new-rule": "Nové pravidlo",
"meal-plan-rules-description": "You can create rules for auto selecting recipes for your meal plans. These rules are used by the server to determine the random pool of recipes to select from when creating meal plans. Note that if rules have the same day/type constraints then the rule filters will be merged. In practice, it's unnecessary to create duplicate rules, but it's possible to do so.", "meal-plan-rules-description": "Můžete vytvořit pravidla pro automatický výběr receptů pro vaše stravovací plány. Tato pravidla používají server k určení náhodného souboru receptů, ze kterých se při vytváření plánů jídla vybírat. Všimněte si, že pokud mají pravidla stejná omezení den/typ, budou filtry pravidel sloučeny. V praxi je zbytečné vytvářet duplicitní pravidla, ale je to možné.",
"new-rule-description": "When creating a new rule for a meal plan you can restrict the rule to be applicable for a specific day of the week and/or a specific type of meal. To apply a rule to all days or all meal types you can set the rule to \"Any\" which will apply it to all the possible values for the day and/or meal type.", "new-rule-description": "Při vytváření nového pravidla pro plán jídla můžete omezit použití pravidla pro konkrétní den v týdnu a/nebo konkrétní druh jídla. Chcete-li použít pravidlo pro všechny dny nebo všechny typy jídla, můžete nastavit pravidlo na \"Jakékoliv\", které se použije na všechny možné hodnoty pro den a/nebo druh jídla.",
"recipe-rules": "Pravidla receptu", "recipe-rules": "Pravidla receptu",
"applies-to-all-days": "Použije se na všechny dny", "applies-to-all-days": "Použije se na všechny dny",
"applies-on-days": "Platí pro {0}", "applies-on-days": "Platí pro {0}",
@ -373,7 +373,7 @@
"recipe-migration": "Přenést recept", "recipe-migration": "Přenést recept",
"chowdown": { "chowdown": {
"description": "Migrovat data z aplikace Chowdown", "description": "Migrovat data z aplikace Chowdown",
"description-long": "Mealie natively supports the chowdown repository format. Download the code repository as a .zip file and upload it below.", "description-long": "Mealie nativně podporuje formát chowdown. Stáhněte si z repozitáře kód jako .zip soubor a nahrajte ho níže.",
"title": "Chowdown" "title": "Chowdown"
}, },
"nextcloud": { "nextcloud": {
@ -403,7 +403,7 @@
"choose-migration-type": "Zvolte si typ migrace", "choose-migration-type": "Zvolte si typ migrace",
"tag-all-recipes": "Označit všechny recepty pomocí štítku {tag-name}", "tag-all-recipes": "Označit všechny recepty pomocí štítku {tag-name}",
"nextcloud-text": "Nextcloud recepty lze importovat ze souboru zip, který obsahuje data uložená v Nextcloudu. Podívejte se na příklad struktury složek níže, abyste se ujistili, že vaše recepty lze importovat.", "nextcloud-text": "Nextcloud recepty lze importovat ze souboru zip, který obsahuje data uložená v Nextcloudu. Podívejte se na příklad struktury složek níže, abyste se ujistili, že vaše recepty lze importovat.",
"chowdown-text": "Mealie natively supports the chowdown repository format. Download the code repository as a .zip file and upload it below.", "chowdown-text": "Mealie nativně podporuje formát chowdown. Stáhněte si z repozitáře kód jako .zip soubor a nahrajte ho níže.",
"recipe-1": "Recept 1", "recipe-1": "Recept 1",
"recipe-2": "Recept 2", "recipe-2": "Recept 2",
"paprika-text": "Mealie může importovat recepty z aplikace Paprika. Exportujte své recepty z papriky, přejmenujte příponu exportovaného souboru na .zip a nahrajte jej níže.", "paprika-text": "Mealie může importovat recepty z aplikace Paprika. Exportujte své recepty z papriky, přejmenujte příponu exportovaného souboru na .zip a nahrajte jej níže.",
@ -414,7 +414,7 @@
}, },
"myrecipebox": { "myrecipebox": {
"title": "My Recipe Box", "title": "My Recipe Box",
"description-long": "Mealie can import recipes from My Recipe Box. Export your recipes in CSV format, then upload the .csv file below." "description-long": "Mealie může importovat recepty z My Recipe Box. Exportujte recepty ve formátu CSV, poté nahrajte soubor .zip níže."
}, },
"recipekeeper": { "recipekeeper": {
"title": "Recipe Keeper", "title": "Recipe Keeper",
@ -580,9 +580,9 @@
"how-did-it-turn-out": "Jak to dopadlo?", "how-did-it-turn-out": "Jak to dopadlo?",
"user-made-this": "{user} udělal toto", "user-made-this": "{user} udělal toto",
"last-made-date": "Naposledy uvařeno {date}", "last-made-date": "Naposledy uvařeno {date}",
"api-extras-description": "Recipes extras are a key feature of the Mealie API. They allow you to create custom JSON key/value pairs within a recipe, to reference from 3rd party applications. You can use these keys to provide information, for example to trigger automations or custom messages to relay to your desired device.", "api-extras-description": "Recepty jsou klíčovým rysem rozhraní pro API Mealie. Umožňují vytvářet vlastní klíče/hodnoty JSON v rámci receptu pro odkazy na aplikace třetích stran. Tyto klíče můžete použít pro poskytnutí informací, například pro aktivaci automatizace nebo vlastních zpráv pro přenos do požadovaného zařízení.",
"message-key": "Message Key", "message-key": "Klíč zprávy",
"parse": "Parse", "parse": "Analyzovat",
"attach-images-hint": "Přiložit obrázky přetažením jich do editoru", "attach-images-hint": "Přiložit obrázky přetažením jich do editoru",
"drop-image": "Vložit obrázek", "drop-image": "Vložit obrázek",
"enable-ingredient-amounts-to-use-this-feature": "Chcete-li tuto funkci používat, povolte množství ingrediencí", "enable-ingredient-amounts-to-use-this-feature": "Chcete-li tuto funkci používat, povolte množství ingrediencí",
@ -607,9 +607,9 @@
"debug-scraper": "Ladící Scraper", "debug-scraper": "Ladící Scraper",
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Vytvořte recept zadáním názvu. Všechny recepty musí mít jedinečná jména.", "create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Vytvořte recept zadáním názvu. Všechny recepty musí mít jedinečná jména.",
"new-recipe-names-must-be-unique": "Názvy receptů musí být jedinečné", "new-recipe-names-must-be-unique": "Názvy receptů musí být jedinečné",
"scrape-recipe": "Scrape Recipe", "scrape-recipe": "Zpracovat recept",
"scrape-recipe-description": "Scrape a recipe by url. Provide the url for the site you want to scrape, and Mealie will attempt to scrape the recipe from that site and add it to your collection.", "scrape-recipe-description": "Zpracovat recept na url. Uveďte adresu url pro str8nku, kterou chcete zpracovat a Mealie se pokusí zpracovat recept z tohoto webu a přidat jej do vaší sbírky.",
"scrape-recipe-have-a-lot-of-recipes": "Have a lot of recipes you want to scrape at once?", "scrape-recipe-have-a-lot-of-recipes": "Máte spoustu receptů, které chcete zpracovat najednou?",
"scrape-recipe-suggest-bulk-importer": "Vyzkoušejte hromadný import", "scrape-recipe-suggest-bulk-importer": "Vyzkoušejte hromadný import",
"scrape-recipe-have-raw-html-or-json-data": "Máte surová data HTML nebo JSON?", "scrape-recipe-have-raw-html-or-json-data": "Máte surová data HTML nebo JSON?",
"scrape-recipe-you-can-import-from-raw-data-directly": "Můžete importovat přímo ze surových dat", "scrape-recipe-you-can-import-from-raw-data-directly": "Můžete importovat přímo ze surových dat",
@ -625,19 +625,19 @@
"create-a-recipe-by-uploading-a-scan": "Vytvořte recept nahráním skenu.", "create-a-recipe-by-uploading-a-scan": "Vytvořte recept nahráním skenu.",
"upload-a-png-image-from-a-recipe-book": "Nahrát png obrázek z knihy receptů", "upload-a-png-image-from-a-recipe-book": "Nahrát png obrázek z knihy receptů",
"recipe-bulk-importer": "Hromadný import receptů", "recipe-bulk-importer": "Hromadný import receptů",
"recipe-bulk-importer-description": "The Bulk recipe importer allows you to import multiple recipes at once by queueing the sites on the backend and running the task in the background. This can be useful when initially migrating to Mealie, or when you want to import a large number of recipes.", "recipe-bulk-importer-description": "Hromadný import receptů vám umožní importovat více receptů najednou tím, že ve frontě stránek na podpůrné vrstvě bude spuštěn úkol na pozadí. To může být užitečné při počáteční migraci na Mealie, nebo když chcete importovat velké množství receptů.",
"set-categories-and-tags": "Nastavte kategorie a štítky", "set-categories-and-tags": "Nastavte kategorie a štítky",
"bulk-imports": "Hromadný import", "bulk-imports": "Hromadný import",
"bulk-import-process-has-started": "Proces hromadného importu byl zahájen", "bulk-import-process-has-started": "Proces hromadného importu byl zahájen",
"bulk-import-process-has-failed": "Proces hromadného importu se nezdařil", "bulk-import-process-has-failed": "Proces hromadného importu se nezdařil",
"report-deletion-failed": "Odstranění reportu se nezdařilo", "report-deletion-failed": "Odstranění reportu se nezdařilo",
"recipe-debugger": "Recipe Debugger", "recipe-debugger": "Ladění receptů",
"recipe-debugger-description": "Grab the URL of the recipe you want to debug and paste it here. The URL will be scraped by the recipe scraper and the results will be displayed. If you don't see any data returned, the site you are trying to scrape is not supported by Mealie or its scraper library.", "recipe-debugger-description": "Získejte URL receptu, který chcete ladit a vložte jej zde. URL bude zpracováno procesorem receptů a budou zobrazeny výsledky. Pokud nevidíte žádná data, stránka, kterou se pokoušíte zpracovat, není podporována ani v Mealie ani jeho knihovně pro zpracování.",
"use-openai": "Použít OpenAI", "use-openai": "Použít OpenAI",
"recipe-debugger-use-openai-description": "Use OpenAI to parse the results instead of relying on the scraper library. When creating a recipe via URL, this is done automatically if the scraper library fails, but you may test it manually here.", "recipe-debugger-use-openai-description": "Použijte OpenAI k analýze výsledků namísto spoléhání se na knihovnu pro zpracování. Při vytváření receptu prostřednictvím adresy URL se to provádí automaticky, pokud knihovna pro zpracování selže, ale můžete to zde otestovat ručně.",
"debug": "Ladit", "debug": "Ladit",
"tree-view": "Stromové zobrazení", "tree-view": "Stromové zobrazení",
"recipe-servings": "Recipe Servings", "recipe-servings": "Počet porcí",
"recipe-yield": "Recipe Yield", "recipe-yield": "Recipe Yield",
"recipe-yield-text": "Recipe Yield Text", "recipe-yield-text": "Recipe Yield Text",
"unit": "Jednotka", "unit": "Jednotka",
@ -647,14 +647,14 @@
"nextStep": "Další krok", "nextStep": "Další krok",
"recipe-actions": "Akce receptu", "recipe-actions": "Akce receptu",
"parser": { "parser": {
"experimental-alert-text": "Mealie uses natural language processing to parse and create units and food items for your recipe ingredients. This feature is experimental and may not always work as expected. If you prefer not to use the parsed results, you can select 'Cancel' and your changes will not be saved.", "experimental-alert-text": "Mealie používá přirozené zpracování jazyka k analýze a vytváření jednotek a položek jídla pro vaše ingredience. Tato funkce je experimentální a nemusí vždy fungovat podle očekávání. Pokud raději nepoužíváte analyzované výsledky, můžete zvolit 'Zrušit' a vaše změny nebudou uloženy.",
"ingredient-parser": "Ingredient Parser", "ingredient-parser": "Analyzátor ingrediencí",
"explanation": "To use the ingredient parser, click the 'Parse All' button to start the process. Once the processed ingredients are available, you can review the items and verify that they were parsed correctly. The model's confidence score is displayed on the right of the item title. This score is an average of all the individual scores and may not always be completely accurate.", "explanation": "Chcete-li použít analyzátor ingrediencí, klikněte na tlačítko \"Analyzovat vše\" pro zahájení procesu. Jakmile budou zpracované suroviny k dispozici, můžete zkontrolovat položky a ověřit, že byly správně analyzovány. Skóre důvěry modelu se zobrazuje vpravo od názvu položky. Toto skóre je průměrem všech jednotlivých skóre a nemusí být vždy zcela přesné.",
"alerts-explainer": "Alerts will be displayed if a matching foods or unit is found but does not exists in the database.", "alerts-explainer": "Upozornění se zobrazí v případě, že je nalezena odpovídající potravina nebo jednotka, ale v databázi neexistuje.",
"select-parser": "Select Parser", "select-parser": "Vyberte analyzátor",
"natural-language-processor": "Natural Language Processor", "natural-language-processor": "Natural Language Processor",
"brute-parser": "Brute Parser", "brute-parser": "Brute Parser",
"openai-parser": "OpenAI Parser", "openai-parser": "Analyzátor OpenAI",
"parse-all": "Parsovat vše", "parse-all": "Parsovat vše",
"no-unit": "Žádná jednotka", "no-unit": "Žádná jednotka",
"missing-unit": "Vytvořit chybějící jednotku: {unit}", "missing-unit": "Vytvořit chybějící jednotku: {unit}",
@ -662,22 +662,22 @@
"no-food": "Žádné jídlo" "no-food": "Žádné jídlo"
}, },
"reset-servings-count": "Resetovat počet porcí", "reset-servings-count": "Resetovat počet porcí",
"not-linked-ingredients": "Additional Ingredients" "not-linked-ingredients": "Další ingredience"
}, },
"recipe-finder": { "recipe-finder": {
"recipe-finder": "Recipe Finder", "recipe-finder": "Vyhledávač receptů",
"recipe-finder-description": "Search for recipes based on ingredients you have on hand. You can also filter by tools you have available, and set a maximum number of missing ingredients or tools.", "recipe-finder-description": "Vyhledávání receptů na základě přísad, které máte na ruce. Můžete také filtrovat pomocí nástrojů, které máte k dispozici, a nastavit maximální počet chybějících ingrediencí nebo nástrojů.",
"selected-ingredients": "Selected Ingredients", "selected-ingredients": "Vybrané ingredience",
"no-ingredients-selected": "No ingredients selected", "no-ingredients-selected": "Nebyly vybrány žádné ingredience",
"missing": "Chybějící", "missing": "Chybějící",
"no-recipes-found": "Nebyly nalezeny žádné recepty", "no-recipes-found": "Nebyly nalezeny žádné recepty",
"no-recipes-found-description": "Try adding more ingredients to your search or adjusting your filters", "no-recipes-found-description": "Zkuste do hledání přidat další ingredience nebo upravit své filtry",
"include-ingredients-on-hand": "Include Ingredients On Hand", "include-ingredients-on-hand": "Zahrnout ingredience jež k dispozici",
"include-tools-on-hand": "Include Tools On Hand", "include-tools-on-hand": "Zahrnout nástroje, které máte po ruce",
"max-missing-ingredients": "Max Missing Ingredients", "max-missing-ingredients": "Maximální počet chybějících ingrediencí",
"max-missing-tools": "Max Missing Tools", "max-missing-tools": "Maximální počet chybějících nástrojů",
"selected-tools": "Selected Tools", "selected-tools": "Vybrané nástroje",
"other-filters": "Other Filters", "other-filters": "Jiné filtry",
"ready-to-make": "Ready to Make", "ready-to-make": "Ready to Make",
"almost-ready-to-make": "Almost Ready to Make" "almost-ready-to-make": "Almost Ready to Make"
}, },
@ -718,9 +718,9 @@
"import-summary": "Shrnutí importu", "import-summary": "Shrnutí importu",
"partial-backup": "Částečná záloha", "partial-backup": "Částečná záloha",
"unable-to-delete-backup": "Zálohu nelze odstranit.", "unable-to-delete-backup": "Zálohu nelze odstranit.",
"experimental-description": "Backups are total snapshots of the database and data directory of the site. This includes all data and cannot be set to exclude subsets of data. You can think of this as a snapshot of Mealie at a specific time. These serve as a database agnostic way to export and import data, or back up the site to an external location.", "experimental-description": "Zálohy jsou celkové snímky databáze a datového adresáře webu. Tato položka zahrnuje všechny údaje a nelze ji nastavit pro vyloučení podsouborů údajů. Můžete ji brát jako snímek Mealie v určitém čase. Tyto slouží jako způsob, jak nezávisle na databázi exportovat a importovat data nebo zálohovat stránky na externí umístění.",
"backup-restore": "Obnova zálohy", "backup-restore": "Obnova zálohy",
"back-restore-description": "Restoring this backup will overwrite all the current data in your database and in the data directory and replace them with the contents of this backup. {cannot-be-undone} If the restoration is successful, you will be logged out.", "back-restore-description": "Obnovení této zálohy přepíše všechna aktuální data ve vaší databázi a v datovém adresáři a nahradí je obsahem této zálohy. {cannot-be-undone} Pokud je obnovení úspěšné, budete odhlášeni.",
"cannot-be-undone": "Tuto akci nelze vrátit zpět - používejte ji s opatrností.", "cannot-be-undone": "Tuto akci nelze vrátit zpět - používejte ji s opatrností.",
"postgresql-note": "Pokud používáte PostgreSQL, před obnovením si prosím přečtete {backup-restore-process}.", "postgresql-note": "Pokud používáte PostgreSQL, před obnovením si prosím přečtete {backup-restore-process}.",
"backup-restore-process-in-the-documentation": "proces zálohy/obnovení v dokumentaci", "backup-restore-process-in-the-documentation": "proces zálohy/obnovení v dokumentaci",
@ -813,11 +813,11 @@
"description": "The webhooks defined below will be executed when a meal is defined for the day. At the scheduled time the webhooks will be sent with the data from the recipe that is scheduled for the day. Note that webhook execution is not exact. The webhooks are executed on a 5 minutes interval so the webhooks will be executed within 5 +/- minutes of the scheduled." "description": "The webhooks defined below will be executed when a meal is defined for the day. At the scheduled time the webhooks will be sent with the data from the recipe that is scheduled for the day. Note that webhook execution is not exact. The webhooks are executed on a 5 minutes interval so the webhooks will be executed within 5 +/- minutes of the scheduled."
}, },
"bug-report": "Chybové hlášení", "bug-report": "Chybové hlášení",
"bug-report-information": "Use this information to report a bug. Providing details of your instance to developers is the best way to get your issues resolved quickly.", "bug-report-information": "Použijte tyto informace k nahlášení chyby. Poskytnutí podrobností vaší instance vývojářům je nejlepší způsob, jak rychle vyřešit vaše problémy.",
"tracker": "Tracker", "tracker": "Tracker",
"configuration": "Konfigurace", "configuration": "Konfigurace",
"docker-volume": "Volume dockeru", "docker-volume": "Volume dockeru",
"docker-volume-help": "Mealie requires that the frontend container and the backend share the same docker volume or storage. This ensures that the frontend container can properly access the images and assets stored on disk.", "docker-volume-help": "Mealie vyžaduje, aby kontejner prostředí a podpůrné vrstvy sdílely stejný úložný prostor dockeru. Tím se zajistí, že kontejner prostředí bude moci správně přistupovat k obrázkům a informacím uloženým na disku.",
"volumes-are-misconfigured": "Svazky jsou špatně nakonfigurovány.", "volumes-are-misconfigured": "Svazky jsou špatně nakonfigurovány.",
"volumes-are-configured-correctly": "Volumy jsou nastaveny správně.", "volumes-are-configured-correctly": "Volumy jsou nastaveny správně.",
"status-unknown-try-running-a-validation": "Neznámý stav. Zkuste provést validaci.", "status-unknown-try-running-a-validation": "Neznámý stav. Zkuste provést validaci.",
@ -884,7 +884,7 @@
"are-you-sure-you-want-to-check-all-items": "Opravdu chcete vybrat všechny položky?", "are-you-sure-you-want-to-check-all-items": "Opravdu chcete vybrat všechny položky?",
"are-you-sure-you-want-to-uncheck-all-items": "Opravdu chcete zrušit výběr všech položek?", "are-you-sure-you-want-to-uncheck-all-items": "Opravdu chcete zrušit výběr všech položek?",
"are-you-sure-you-want-to-delete-checked-items": "Opravdu chcete odstranit všechny vybrané položky?", "are-you-sure-you-want-to-delete-checked-items": "Opravdu chcete odstranit všechny vybrané položky?",
"no-shopping-lists-found": "No Shopping Lists Found" "no-shopping-lists-found": "Nebyly nalezeny žádné nákupní seznamy"
}, },
"sidebar": { "sidebar": {
"all-recipes": "Všechny recepty", "all-recipes": "Všechny recepty",
@ -1059,14 +1059,14 @@
"food-label": "Označení jídla", "food-label": "Označení jídla",
"edit-food": "Upravit jídlo", "edit-food": "Upravit jídlo",
"food-data": "Data jídla", "food-data": "Data jídla",
"example-food-singular": "ex: Onion", "example-food-singular": "např.: Brambora",
"example-food-plural": "ex: Onions", "example-food-plural": "např.: Brambory",
"label-overwrite-warning": "Toto přiřadí vybraný štítek všem vybraným jídlům a může přepsat stávající štítky.", "label-overwrite-warning": "Toto přiřadí vybraný štítek všem vybraným jídlům a může přepsat stávající štítky.",
"on-hand-checkbox-label": "Nastavením tohoto příznaku bude tato potravina při přidávání receptu do nákupního seznamu ve výchozím nastavení odškrtnuta." "on-hand-checkbox-label": "Nastavením tohoto příznaku bude tato potravina při přidávání receptu do nákupního seznamu ve výchozím nastavení odškrtnuta."
}, },
"units": { "units": {
"seed-dialog-text": "Naplnit databázi s běžnými jednotkami používanými ve vašem jazyce.", "seed-dialog-text": "Naplnit databázi s běžnými jednotkami používanými ve vašem jazyce.",
"combine-unit-description": "Combining the selected units will merge the Source Unit and Target Unit into a single unit. The {source-unit-will-be-deleted} and all of the references to the Source Unit will be updated to point to the Target Unit.", "combine-unit-description": "Zkombinování zvolených jednotek spojí zdrojovou a cílovou jednotku do jedné. {source-unit-will-be-deleted} a všechny odkazy na ni budou upraveny na cílovou jednotku.",
"combine-unit": "Kombinovaná jednotka", "combine-unit": "Kombinovaná jednotka",
"source-unit": "Zdrojová jednotka", "source-unit": "Zdrojová jednotka",
"target-unit": "Cílová jednotka", "target-unit": "Cílová jednotka",
@ -1081,10 +1081,10 @@
"unit-data": "Data jednotky", "unit-data": "Data jednotky",
"use-abbv": "Používat zkratky", "use-abbv": "Používat zkratky",
"fraction": "Zlomek", "fraction": "Zlomek",
"example-unit-singular": "ex: Tablespoon", "example-unit-singular": "např.: Čajová lžička",
"example-unit-plural": "ex: Tablespoons", "example-unit-plural": "např.: Čajové lžičky",
"example-unit-abbreviation-singular": "ex: Tbsp", "example-unit-abbreviation-singular": "např.: čl",
"example-unit-abbreviation-plural": "ex: Tbsps" "example-unit-abbreviation-plural": "např.: čl"
}, },
"labels": { "labels": {
"seed-dialog-text": "Naplnit databázi s běžnými popisky používanými ve vašem jazyce.", "seed-dialog-text": "Naplnit databázi s běžnými popisky používanými ve vašem jazyce.",
@ -1296,7 +1296,7 @@
"profile": { "profile": {
"welcome-user": "👋 Vítejte, {0}!", "welcome-user": "👋 Vítejte, {0}!",
"description": "Spravujte svůj profil, recepty a nastavení skupiny.", "description": "Spravujte svůj profil, recepty a nastavení skupiny.",
"invite-link": "Invite Link", "invite-link": "Odkaz pozvánky",
"get-invite-link": "Získat odkaz na pozvánku", "get-invite-link": "Získat odkaz na pozvánku",
"get-public-link": "Získat veřejný odkaz", "get-public-link": "Získat veřejný odkaz",
"account-summary": "Přehled účtu", "account-summary": "Přehled účtu",
@ -1346,8 +1346,8 @@
"cookbook": { "cookbook": {
"cookbooks": "Kuchařky", "cookbooks": "Kuchařky",
"description": "Kuchařky jsou dalším způsobem, jak uspořádat recepty vytvořením průřezů receptů, organizátorů a dalších filtrů. Vytvořením kuchařky se přidá položka na postranní panel a v kuchařce se zobrazí všechny recepty s vybranými filtry.", "description": "Kuchařky jsou dalším způsobem, jak uspořádat recepty vytvořením průřezů receptů, organizátorů a dalších filtrů. Vytvořením kuchařky se přidá položka na postranní panel a v kuchařce se zobrazí všechny recepty s vybranými filtry.",
"hide-cookbooks-from-other-households": "Hide Cookbooks from Other Households", "hide-cookbooks-from-other-households": "Skrýt kuchařky ostatních domácností",
"hide-cookbooks-from-other-households-description": "When enabled, only cookbooks from your household will appear on the sidebar", "hide-cookbooks-from-other-households-description": "Pokud je povoleno, objeví se na postranním panelu pouze kuchařské knihy z vaší domácnosti",
"public-cookbook": "Veřejná kuchařka", "public-cookbook": "Veřejná kuchařka",
"public-cookbook-description": "Veřejné kuchařky mohou být sdíleny s neregistrovanými uživateli a budou zobrazeny na stránce vaší skupiny.", "public-cookbook-description": "Veřejné kuchařky mohou být sdíleny s neregistrovanými uživateli a budou zobrazeny na stránce vaší skupiny.",
"filter-options": "Možnosti filtru", "filter-options": "Možnosti filtru",

View file

@ -277,7 +277,7 @@
"admin-group-management-text": "Los cambios en este grupo se reflejarán inmediatamente.", "admin-group-management-text": "Los cambios en este grupo se reflejarán inmediatamente.",
"group-id-value": "Id del Grupo: {0}", "group-id-value": "Id del Grupo: {0}",
"total-households": "Total de Casas", "total-households": "Total de Casas",
"you-must-select-a-group-before-selecting-a-household": "You must select a group before selecting a household" "you-must-select-a-group-before-selecting-a-household": "Debe seleccionar un grupo antes de seleccionar un hogar"
}, },
"household": { "household": {
"household": "Casa", "household": "Casa",
@ -518,7 +518,7 @@
"save-recipe-before-use": "Guardar la receta antes de usar", "save-recipe-before-use": "Guardar la receta antes de usar",
"section-title": "Título de la sección", "section-title": "Título de la sección",
"servings": "Porciones", "servings": "Porciones",
"serves-amount": "Serves {amount}", "serves-amount": "Personas {amount}",
"share-recipe-message": "Quería compartir mi receta {0} contigo.", "share-recipe-message": "Quería compartir mi receta {0} contigo.",
"show-nutrition-values": "Mostrar valores nutricionales", "show-nutrition-values": "Mostrar valores nutricionales",
"sodium-content": "Sodio", "sodium-content": "Sodio",
@ -547,8 +547,8 @@
"failed-to-add-recipe-to-mealplan": "Error al añadir receta al menú", "failed-to-add-recipe-to-mealplan": "Error al añadir receta al menú",
"failed-to-add-to-list": "No se pudo agregar a la lista", "failed-to-add-to-list": "No se pudo agregar a la lista",
"yield": "Raciones", "yield": "Raciones",
"yields-amount-with-text": "Yields {amount} {text}", "yields-amount-with-text": "Raciones {amount} {text}",
"yield-text": "Yield Text", "yield-text": "Texto de raciones",
"quantity": "Cantidad", "quantity": "Cantidad",
"choose-unit": "Elija unidad", "choose-unit": "Elija unidad",
"press-enter-to-create": "Presione Intro para crear", "press-enter-to-create": "Presione Intro para crear",
@ -637,9 +637,9 @@
"recipe-debugger-use-openai-description": "Utilice OpenAI para analizar los resultados en lugar de depender de la biblioteca de analizadores. Cuando se crea una receta a través de la URL, esto se hace automáticamente si la biblioteca del analizador falla, pero puede probarla manualmente aquí.", "recipe-debugger-use-openai-description": "Utilice OpenAI para analizar los resultados en lugar de depender de la biblioteca de analizadores. Cuando se crea una receta a través de la URL, esto se hace automáticamente si la biblioteca del analizador falla, pero puede probarla manualmente aquí.",
"debug": "Depuración", "debug": "Depuración",
"tree-view": "Vista en árbol", "tree-view": "Vista en árbol",
"recipe-servings": "Recipe Servings", "recipe-servings": "Cantidad de personas",
"recipe-yield": "Porciones", "recipe-yield": "Porciones",
"recipe-yield-text": "Recipe Yield Text", "recipe-yield-text": "Texto de raciones totales",
"unit": "Unidades", "unit": "Unidades",
"upload-image": "Subir imagen", "upload-image": "Subir imagen",
"screen-awake": "Mantener la pantalla encendida", "screen-awake": "Mantener la pantalla encendida",
@ -662,24 +662,24 @@
"no-food": "Sin Comida" "no-food": "Sin Comida"
}, },
"reset-servings-count": "Restablecer contador de porciones", "reset-servings-count": "Restablecer contador de porciones",
"not-linked-ingredients": "Additional Ingredients" "not-linked-ingredients": "Ingredientes adicionales"
}, },
"recipe-finder": { "recipe-finder": {
"recipe-finder": "Recipe Finder", "recipe-finder": "Buscador de recetas",
"recipe-finder-description": "Search for recipes based on ingredients you have on hand. You can also filter by tools you have available, and set a maximum number of missing ingredients or tools.", "recipe-finder-description": "Busca recetas basadas en los ingredientes que tengas disponibles. También puede filtrar por utensilios disponibles, y establecer un número máximo de ingredientes o herramientas que faltan.",
"selected-ingredients": "Ingredientes seleccionados", "selected-ingredients": "Ingredientes seleccionados",
"no-ingredients-selected": "Ningún ingrediente seleccionado", "no-ingredients-selected": "Ningún ingrediente seleccionado",
"missing": "Missing", "missing": "Faltan",
"no-recipes-found": "No se encontraron recetas", "no-recipes-found": "No se encontraron recetas",
"no-recipes-found-description": "Intenta añadir más ingredientes a tu búsqueda o ajustar tus filtros", "no-recipes-found-description": "Intenta añadir más ingredientes a tu búsqueda o ajustar tus filtros",
"include-ingredients-on-hand": "Incluye ingredientes a mano", "include-ingredients-on-hand": "Incluye ingredientes a mano",
"include-tools-on-hand": "Include Tools On Hand", "include-tools-on-hand": "Incluye utensilios disponibles",
"max-missing-ingredients": "Max Missing Ingredients", "max-missing-ingredients": "Máximo de ingredientes que faltan",
"max-missing-tools": "Max Missing Tools", "max-missing-tools": "Máximo de utensilios que faltan",
"selected-tools": "Selected Tools", "selected-tools": "Utensilios seleccionados",
"other-filters": "Other Filters", "other-filters": "Otros filtros",
"ready-to-make": "Ready to Make", "ready-to-make": "Listo para hacer",
"almost-ready-to-make": "Almost Ready to Make" "almost-ready-to-make": "Casi listo para hacer"
}, },
"search": { "search": {
"advanced-search": "Búsqueda avanzada", "advanced-search": "Búsqueda avanzada",
@ -884,7 +884,7 @@
"are-you-sure-you-want-to-check-all-items": "¿Seguro que quieres seleccionar todos los elementos?", "are-you-sure-you-want-to-check-all-items": "¿Seguro que quieres seleccionar todos los elementos?",
"are-you-sure-you-want-to-uncheck-all-items": "¿Seguro que quieres de-seleccionar todos los elementos?", "are-you-sure-you-want-to-uncheck-all-items": "¿Seguro que quieres de-seleccionar todos los elementos?",
"are-you-sure-you-want-to-delete-checked-items": "¿Está seguro que deseas eliminar los elementos seleccionados?", "are-you-sure-you-want-to-delete-checked-items": "¿Está seguro que deseas eliminar los elementos seleccionados?",
"no-shopping-lists-found": "No Shopping Lists Found" "no-shopping-lists-found": "No hay listas de la compra"
}, },
"sidebar": { "sidebar": {
"all-recipes": "Recetas", "all-recipes": "Recetas",
@ -1296,7 +1296,7 @@
"profile": { "profile": {
"welcome-user": "👋 ¡Bienvenido, {0}!", "welcome-user": "👋 ¡Bienvenido, {0}!",
"description": "Administra tu perfil, recetas y ajustes de grupo.", "description": "Administra tu perfil, recetas y ajustes de grupo.",
"invite-link": "Invite Link", "invite-link": "Link de invitación",
"get-invite-link": "Obtener enlace de invitación", "get-invite-link": "Obtener enlace de invitación",
"get-public-link": "Obtener enlace público", "get-public-link": "Obtener enlace público",
"account-summary": "Información de la cuenta", "account-summary": "Información de la cuenta",
@ -1346,7 +1346,7 @@
"cookbook": { "cookbook": {
"cookbooks": "Recetarios", "cookbooks": "Recetarios",
"description": "Los recetarios son otra forma de organizar recetas creando secciones cruzadas de recetas y etiquetas. Crear un recetario añadirá una entrada a la barra lateral y todas las recetas con las etiquetas y categorías elegidas se mostrarán en el recetario.", "description": "Los recetarios son otra forma de organizar recetas creando secciones cruzadas de recetas y etiquetas. Crear un recetario añadirá una entrada a la barra lateral y todas las recetas con las etiquetas y categorías elegidas se mostrarán en el recetario.",
"hide-cookbooks-from-other-households": "Hide Cookbooks from Other Households", "hide-cookbooks-from-other-households": "Ocultar libros de cocina de otros grupos/hogares",
"hide-cookbooks-from-other-households-description": "Cuando esté habilitado, sólo los libros de cocina de su hogar aparecerán en la barra lateral", "hide-cookbooks-from-other-households-description": "Cuando esté habilitado, sólo los libros de cocina de su hogar aparecerán en la barra lateral",
"public-cookbook": "Recetario público", "public-cookbook": "Recetario público",
"public-cookbook-description": "Los recetarios públicos se pueden compartir con usuarios externos y se mostrarán en su página de grupos.", "public-cookbook-description": "Los recetarios públicos se pueden compartir con usuarios externos y se mostrarán en su página de grupos.",

View file

@ -972,416 +972,416 @@
"new-password": "Uus salasõna", "new-password": "Uus salasõna",
"new-user": "Uus kasutaja", "new-user": "Uus kasutaja",
"password-has-been-reset-to-the-default-password": "Salasõna on lähtestatud selle vaikeväärtusega.", "password-has-been-reset-to-the-default-password": "Salasõna on lähtestatud selle vaikeväärtusega.",
"password-must-match": "Password must match", "password-must-match": "Salasõnad peavad kattuma",
"password-reset-failed": "Password reset failed", "password-reset-failed": "Salasõna lähtestamine ebaõnnestus",
"password-updated": "Password updated", "password-updated": "Salasõna uuendatud",
"password": "Password", "password": "Salasõna",
"password-strength": "Password is {strength}", "password-strength": "Salasõna on {strength}",
"please-enter-password": "Please enter your new password.", "please-enter-password": "Palun sisesta oma uus salasõna.",
"register": "Register", "register": "Registreeri",
"reset-password": "Reset Password", "reset-password": "Lähtesta salasõna",
"sign-in": "Sign in", "sign-in": "Logi sisse",
"total-mealplans": "Total MealPlans", "total-mealplans": "Kõik toitumisplaanid",
"total-users": "Total Users", "total-users": "Kõik kasutajad",
"upload-photo": "Upload Photo", "upload-photo": "Lae pilt üles",
"use-8-characters-or-more-for-your-password": "Use 8 characters or more for your password", "use-8-characters-or-more-for-your-password": "Kasutage oma salasõna jaoks vähemalt 8 tähemärki.",
"user-created": "User created", "user-created": "Kasutaja loodud",
"user-creation-failed": "User creation failed", "user-creation-failed": "Kasutaja loomine ebaõnnestus",
"user-deleted": "User deleted", "user-deleted": "Kasutaja kustutatud",
"user-id-with-value": "User ID: {id}", "user-id-with-value": "Kasutaja ID: {id}",
"user-id": "User ID", "user-id": "Kasutaja ID",
"user-password": "User Password", "user-password": "Kasutaja salasõna",
"user-successfully-logged-in": "User Successfully Logged In", "user-successfully-logged-in": "Kasutaja edukalt sisse logitud",
"user-update-failed": "User update failed", "user-update-failed": "Kasutaja uuendamine ebaõnnestus",
"user-updated": "User updated", "user-updated": "Kasutaja uuendatud",
"user": "User", "user": "Kasutaja",
"username": "Username", "username": "Kasutajanimi",
"users-header": "USERS", "users-header": "KASUTAJAD",
"users": "Users", "users": "Kasutajad",
"user-not-found": "User not found", "user-not-found": "Kasutajaid ei leitud",
"webhook-time": "Webhook Time", "webhook-time": "Webhooki nimi",
"webhooks-enabled": "Webhooks Enabled", "webhooks-enabled": "Webhookid lubatud",
"you-are-not-allowed-to-create-a-user": "You are not allowed to create a user", "you-are-not-allowed-to-create-a-user": "Sul ei ole õigust luua uut kasutajat",
"you-are-not-allowed-to-delete-this-user": "You are not allowed to delete this user", "you-are-not-allowed-to-delete-this-user": "Sul ei ole õigust kustutada seda kasutajat",
"enable-advanced-content": "Enable Advanced Content", "enable-advanced-content": "Lubage täpsem sisu",
"enable-advanced-content-description": "Enables advanced features like Recipe Scaling, API keys, Webhooks, and Data Management. Don't worry, you can always change this later", "enable-advanced-content-description": "Võimaldab täiustatud funktsioone, nagu retseptide skaleerimine, API võtmed, veebihaagid ja andmehaldus. Ärge muretsege, saate seda alati hiljem muuta.",
"favorite-recipes": "Favorite Recipes", "favorite-recipes": "Lemmikretseptid",
"email-or-username": "Email or Username", "email-or-username": "Email või kasutajanimi",
"remember-me": "Remember Me", "remember-me": "Mäleta mind",
"please-enter-your-email-and-password": "Please enter your email and password", "please-enter-your-email-and-password": "Palun sisesta oma email ja salasõna",
"invalid-credentials": "Invalid Credentials", "invalid-credentials": "Valed kasutajaandmed",
"account-locked-please-try-again-later": "Account Locked. Please try again later", "account-locked-please-try-again-later": "Kasutaja lukustatud. Palun proovi hiljem uuesti",
"user-favorites": "User Favorites", "user-favorites": "Kasutaja lemmikud",
"password-strength-values": { "password-strength-values": {
"weak": "Weak", "weak": "Nõrk",
"good": "Good", "good": "Hea",
"strong": "Strong", "strong": "Tugev",
"very-strong": "Very Strong" "very-strong": "Väga tugev"
}, },
"user-management": "User Management", "user-management": "Kasutajate haldamine",
"reset-locked-users": "Reset Locked Users", "reset-locked-users": "Lähtesta lukustatud kasutajad",
"admin-user-creation": "Admin User Creation", "admin-user-creation": "Administraatori kasutaja loomine",
"admin-user-management": "Admin User Management", "admin-user-management": "Administraator-kasutaja haldamine",
"user-details": "User Details", "user-details": "Kasutaja üksikasjad",
"user-name": "User Name", "user-name": "Kasutaja nimi",
"authentication-method": "Authentication Method", "authentication-method": "Autentimise meetod",
"authentication-method-hint": "This specifies how a user will authenticate with Mealie. If you're not sure, choose 'Mealie'", "authentication-method-hint": "Sellega määratakse kuidas kasutaja autendib Mealiega. Kui sa pole kindel, siis vali \"Mealie\"",
"permissions": "Permissions", "permissions": "Õigused",
"administrator": "Administrator", "administrator": "Administraator",
"user-can-invite-other-to-group": "User can invite others to group", "user-can-invite-other-to-group": "Kasutaja võib kutsuda teisi gruppi",
"user-can-manage-group": "User can manage group", "user-can-manage-group": "Kasutaja võib hallata gruppi",
"user-can-manage-household": "User can manage household", "user-can-manage-household": "Kasutaja võib hallata leibkonda",
"user-can-organize-group-data": "User can organize group data", "user-can-organize-group-data": "Kasutaja võin organiseerida grupi andmeid",
"enable-advanced-features": "Enable advanced features", "enable-advanced-features": "Luba laiendatud funktionaalsus",
"it-looks-like-this-is-your-first-time-logging-in": "It looks like this is your first time logging in.", "it-looks-like-this-is-your-first-time-logging-in": "Paistab, et see on sinu esimene sisse logimine",
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Don't want to see this anymore? Be sure to change your email in your user settings!", "dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Ei taha enam seda näha? Ära unusta muuta oma email kasutaja seadetes",
"forgot-password": "Forgot Password", "forgot-password": "Unustasid salasõna",
"forgot-password-text": "Please enter your email address and we will send you a link to reset your password.", "forgot-password-text": "Sisestage oma meiliaadress, et saada e-kiri uue salasõna määramiseks.",
"changes-reflected-immediately": "Changes to this user will be reflected immediately." "changes-reflected-immediately": "Selle kasutaja muudatused on koheselt nähtaval"
}, },
"language-dialog": { "language-dialog": {
"translated": "translated", "translated": "tõlgitud",
"choose-language": "Choose Language", "choose-language": "Vali keel",
"select-description": "Choose the language for the Mealie UI. The setting only applies to you, not other users.", "select-description": "Vali keel Mealie UI jaoks. See valik mõjutab ainult sind, ning ei muuda midagi teiste kasutajate jaoks.",
"how-to-contribute-description": "Is something not translated yet, mistranslated, or your language missing from the list? {read-the-docs-link} on how to contribute!", "how-to-contribute-description": "On midagi jäänud tõlkimata, on valesti tõlgitud või sinu keel puudub nimekirjast täielikult? {read-the-docs-link} panustamiseks!",
"read-the-docs": "Read the docs" "read-the-docs": "Loe dokumentatsiooni"
}, },
"data-pages": { "data-pages": {
"foods": { "foods": {
"merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.", "merge-dialog-text": "Valitud toitude kombineerimine ühendab koostisained ja keskendub ühele roale. Lähtetoidud eemaldatakse ja kõik viited lähtetoidule värskendatakse, et osutada sihttoidule.",
"merge-food-example": "Merging {food1} into {food2}", "merge-food-example": "{food1} liitmine {food2}-ga",
"seed-dialog-text": "Seed the database with foods based on your local language. This will create 200+ common foods that can be used to organize your database. Foods are translated via a community effort.", "seed-dialog-text": "Lisage roogasid andmebaasi kohalikus keeles. See loob üle 200 levinud tooraine, mida saab kasutada andmebaasi korrastamiseks. Tooraineid tõlgitakse kogukonna tegevuste kaudu.",
"seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually.", "seed-dialog-warning": "Teie andmebaasis on juba mõned üksused. See funktsioon ei sobi duplikaatidega, peate neid käsitsi redigeerima.",
"combine-food": "Combine Food", "combine-food": "Liida toidud",
"source-food": "Source Food", "source-food": "Lähtetoid",
"target-food": "Target Food", "target-food": "Sihttoit",
"create-food": "Create Food", "create-food": "Loo toit",
"food-label": "Food Label", "food-label": "Toidu silt",
"edit-food": "Edit Food", "edit-food": "Muuda toitu",
"food-data": "Food Data", "food-data": "Toidu andmed",
"example-food-singular": "ex: Onion", "example-food-singular": "nt:: Sibul",
"example-food-plural": "ex: Onions", "example-food-plural": "nt: Sibulad",
"label-overwrite-warning": "This will assign the chosen label to all selected foods and potentially overwrite your existing labels.", "label-overwrite-warning": "See määrab valitud sildi kõigile valitud toitudele ja potentsiaalselt asendab olemasolevad sildid.",
"on-hand-checkbox-label": "Setting this flag will make this food unchecked by default when adding a recipe to a shopping list." "on-hand-checkbox-label": "Sildi valimine eemaldab toidu vaikimisi nimekirjast kui lisate retsepti ostunimekirja."
}, },
"units": { "units": {
"seed-dialog-text": "Seed the database with common units based on your local language.", "seed-dialog-text": "Alustage andmebaasi täitmist lisades tüüpilised elemendid kohalikus keeles.",
"combine-unit-description": "Combining the selected units will merge the Source Unit and Target Unit into a single unit. The {source-unit-will-be-deleted} and all of the references to the Source Unit will be updated to point to the Target Unit.", "combine-unit-description": "Valitud toitude kombineerimine ühendab koostisained ja keskendub ühele roale. {source-unit-will-be-deleted} eemaldatakse ja kõik viited lähtetoidule värskendatakse, et osutada sihttoidule.",
"combine-unit": "Combine Unit", "combine-unit": "Liitke üksused",
"source-unit": "Source Unit", "source-unit": "Lähteüksus",
"target-unit": "Target Unit", "target-unit": "Sihtüksus",
"merging-unit-into-unit": "Merging {0} into {1}", "merging-unit-into-unit": "{0} liitmine {1}-ga",
"create-unit": "Create Unit", "create-unit": "Loo üksus",
"abbreviation": "Abbreviation", "abbreviation": "Lühend",
"plural-abbreviation": "Plural Abbreviation", "plural-abbreviation": "Mitmuse lühend",
"description": "Description", "description": "Kirjeldus",
"display-as-fraction": "Display as Fraction", "display-as-fraction": "Näita murdosana",
"use-abbreviation": "Use Abbreviation", "use-abbreviation": "Näita lühendit",
"edit-unit": "Edit Unit", "edit-unit": "Muuda lühendit",
"unit-data": "Unit Data", "unit-data": "Üksuse andmed",
"use-abbv": "Use Abbv.", "use-abbv": "Kasuta lühendit",
"fraction": "Fraction", "fraction": "Murdosa",
"example-unit-singular": "ex: Tablespoon", "example-unit-singular": "nt: Supilusikas",
"example-unit-plural": "ex: Tablespoons", "example-unit-plural": "nt: Supilusikad",
"example-unit-abbreviation-singular": "ex: Tbsp", "example-unit-abbreviation-singular": "nt: sl",
"example-unit-abbreviation-plural": "ex: Tbsps" "example-unit-abbreviation-plural": "nt: sl"
}, },
"labels": { "labels": {
"seed-dialog-text": "Seed the database with common labels based on your local language.", "seed-dialog-text": "Alusta andmebaasi täitmist tüüpiliste siltidega kohalikus keeles.",
"edit-label": "Edit Label", "edit-label": "Muuda silti",
"new-label": "New Label", "new-label": "Uus silt",
"labels": "Labels", "labels": "Sildid",
"assign-label": "Assign Label" "assign-label": "Määra silt"
}, },
"recipes": { "recipes": {
"purge-exports": "Purge Exports", "purge-exports": "Puhasta ekspordid",
"are-you-sure-you-want-to-delete-all-export-data": "Are you sure you want to delete all export data?", "are-you-sure-you-want-to-delete-all-export-data": "Kas oled kindel, et tahad kustutada kõik ekspodi andmed",
"confirm-delete-recipes": "Are you sure you want to delete the following recipes? This action cannot be undone.", "confirm-delete-recipes": "Kas oled kindel, et tahad kustutada järgnevad retseptid? Seda tegevust ei saa tagasi võtta.",
"the-following-recipes-selected-length-will-be-exported": "The following recipes ({0}) will be exported.", "the-following-recipes-selected-length-will-be-exported": "Järgnevad retseptid ({0}) eksporditakse.",
"settings-chosen-explanation": "Settings chosen here, excluding the locked option, will be applied to all selected recipes.", "settings-chosen-explanation": "Siin valitud sätted, välja arvatud lukustatud valik, rakenduvad kõikidele valitud retseptidele.",
"selected-length-recipe-s-settings-will-be-updated": "{count} recipe(s) settings will be updated.", "selected-length-recipe-s-settings-will-be-updated": "{count} retsepti sätted uuendatakse",
"recipe-data": "Recipe Data", "recipe-data": "Retsepti andmed",
"recipe-data-description": "Use this section to manage the data associated with your recipes. You can perform several bulk actions on your recipes including exporting, deleting, tagging, and assigning categories.", "recipe-data-description": "See jaotis võimaldab hallata oma retseptidega seotud teavet. Saate oma retseptides teha mitu hulgitöötlust, sealhulgas eksportida, kustutada, sildistada ja kategooriaid määrata.",
"recipe-columns": "Recipe Columns", "recipe-columns": "Retsepti tulbad",
"data-exports-description": "This section provides links to available exports that are ready to download. These exports do expire, so be sure to grab them while they're still available.", "data-exports-description": "See jaotis sisaldab linke saadaolevatele eksportimistele, mis on allalaadimiseks valmis. Need aeguvad, seega salvestage need veel kuni need on saadaval.",
"data-exports": "Data Exports", "data-exports": "Admete eksportimine",
"tag": "Tag", "tag": "Märgis",
"categorize": "Categorize", "categorize": "Kategoriseeri",
"update-settings": "Update Settings", "update-settings": "Uuenda seaded",
"tag-recipes": "Tag Recipes", "tag-recipes": "Märgista retseptid",
"categorize-recipes": "Categorize Recipes", "categorize-recipes": "Kategoriseeri retseptid",
"export-recipes": "Export Recipes", "export-recipes": "Ekspordi retseptid",
"delete-recipes": "Delete Recipes", "delete-recipes": "Kustuta retseptid",
"source-unit-will-be-deleted": "Source Unit will be deleted" "source-unit-will-be-deleted": "Lähteüksis kustutatakse"
}, },
"recipe-actions": { "recipe-actions": {
"recipe-actions-data": "Recipe Actions Data", "recipe-actions-data": "Retsepti kasutusteave",
"new-recipe-action": "New Recipe Action", "new-recipe-action": "Uus retsepti tegevus",
"edit-recipe-action": "Edit Recipe Action", "edit-recipe-action": "Muuda retsepti tegevust",
"action-type": "Action Type" "action-type": "Tegevuse tüüp"
}, },
"create-alias": "Create Alias", "create-alias": "Loo alias",
"manage-aliases": "Manage Aliases", "manage-aliases": "Halda aliaseid",
"seed-data": "Seed Data", "seed-data": "Alusta andmebaasiga",
"seed": "Seed", "seed": "Lisa baasandmed",
"data-management": "Data Management", "data-management": "Andmehaldus",
"data-management-description": "Select which data set you want to make changes to.", "data-management-description": "Vali millisele andmekogule soovid teha muudatused",
"select-data": "Select Data", "select-data": "Vali andmed",
"select-language": "Select Language", "select-language": "Vali keel",
"columns": "Columns", "columns": "Tulbad",
"combine": "Combine", "combine": "Kombineeri",
"categories": { "categories": {
"edit-category": "Edit Category", "edit-category": "Muuda kategooriat",
"new-category": "New Category", "new-category": "Uus kategooria",
"category-data": "Category Data" "category-data": "Kategooria andmed"
}, },
"tags": { "tags": {
"new-tag": "New Tag", "new-tag": "Uus silt",
"edit-tag": "Edit Tag", "edit-tag": "Muuda silti",
"tag-data": "Tag Data" "tag-data": "Sildi andmed"
}, },
"tools": { "tools": {
"new-tool": "New Tool", "new-tool": "Uus tööriist",
"edit-tool": "Edit Tool", "edit-tool": "Muuda tööriista",
"tool-data": "Tool Data" "tool-data": "Tööriista andmed"
} }
}, },
"user-registration": { "user-registration": {
"user-registration": "User Registration", "user-registration": "Kasutaja registreerimine",
"registration-success": "Registration Success", "registration-success": "Edukas registreerimine",
"join-a-group": "Join a Group", "join-a-group": "Liitu grupiga",
"create-a-new-group": "Create a New Group", "create-a-new-group": "Loo uus grupp",
"provide-registration-token-description": "Please provide the registration token associated with the group that you'd like to join. You'll need to obtain this from an existing group member.", "provide-registration-token-description": "Sisestage registreerimistunnus, et liituda grupiga, millega soovite liituda. Peate selle hankima olemasolevalt rühmaliikmelt.",
"group-details": "Group Details", "group-details": "Grupi detailid",
"group-details-description": "Before you create an account you'll need to create a group. Your group will only contain you, but you'll be able to invite others later. Members in your group can share meal plans, shopping lists, recipes, and more!", "group-details-description": "Sa pead looma grupi enne konto loomist. Sinu grupis oled vaid sina, kuid sa saad kutsuda teisi sinna hiljem. Su grupi liikmed saavad jagada toitumisplaane, ostunimekirju, retsepte ja muud!",
"use-seed-data": "Use Seed Data", "use-seed-data": "Kasuta baasandmete infot.",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes.", "use-seed-data-description": "Mealsiga on kaasas toiduainete, ühikute ja siltide kogu, mida saate kasutada oma rühma täitmiseks kasuliku teabega retseptide korraldamiseks.",
"account-details": "Account Details" "account-details": "Konto üksikasjad"
}, },
"validation": { "validation": {
"group-name-is-taken": "Group name is taken", "group-name-is-taken": "Grupi nimi on juba kasutusel",
"username-is-taken": "Username is taken", "username-is-taken": "Kasutajanimi on juba kasutusel",
"email-is-taken": "Email is taken", "email-is-taken": "Meiliaadress on juba kasutusel",
"this-field-is-required": "This Field is Required" "this-field-is-required": "Lahtri täitmine on kohustuslik"
}, },
"export": { "export": {
"export": "Export", "export": "Ekspordi",
"file-name": "File Name", "file-name": "Faili nimi",
"size": "Size", "size": "Suurus",
"link-expires": "Link Expires" "link-expires": "Link aegub"
}, },
"recipe-share": { "recipe-share": {
"expiration-date": "Expiration Date", "expiration-date": "Aegumise kuupäev",
"share-recipe": "Share Recipe", "share-recipe": "Jaga retsepti",
"default-30-days": "Default 30 Days", "default-30-days": "Vaike päevade arv on 30",
"expires-at": "Expires At", "expires-at": "Aegub ajal",
"recipe-link-copied-message": "Recipe link copied to clipboard" "recipe-link-copied-message": "Retsepti link on kopeeritud puhvrisse"
}, },
"banner-experimental": { "banner-experimental": {
"title": "Experimental Feature", "title": "Eksperimentaalne funktsionaalsus",
"description": "This page contains experimental or still-baking features. Please excuse the mess.", "description": "See lehekülg sisaldab eksperimentaalseid või alles valmivaid funktsionaalsuseid. Vabandust segaduse eest.",
"issue-link-text": "Track our progress here" "issue-link-text": "Jälgi progressi siin"
}, },
"form": { "form": {
"quantity-label-abbreviated": "Qty" "quantity-label-abbreviated": "Kogus"
}, },
"markdown-editor": { "markdown-editor": {
"preview-markdown-button-label": "Preview Markdown" "preview-markdown-button-label": "Markdowni eelvaade"
}, },
"demo": { "demo": {
"info_message_with_version": "This is a Demo for version: {version}", "info_message_with_version": "See on versiooni {version} demo",
"demo_username": "Username: {username}", "demo_username": "Kasutajanimi: {username}",
"demo_password": "Password: {password}" "demo_password": "Salasõna: {password}"
}, },
"ocr-editor": { "ocr-editor": {
"ocr-editor": "Ocr editor", "ocr-editor": "Ocr-redigeerija",
"toolbar": "Toolbar", "toolbar": "Tööriistariba",
"selection-mode": "Selection mode", "selection-mode": "Valimise režiim",
"pan-and-zoom-picture": "Pan and zoom picture", "pan-and-zoom-picture": "Pildi liigutamine ja zoomimine",
"split-text": "Split text", "split-text": "Poolita tekst",
"preserve-line-breaks": "Preserve original line breaks", "preserve-line-breaks": "Jäta originaalsed reavahetused",
"split-by-block": "Split by text block", "split-by-block": "Poolita tekstiploki järgi",
"flatten": "Flatten regardless of original formating", "flatten": "Tasanda järgimata originaalset teksti vormindamist",
"help": { "help": {
"help": "Help", "help": "Abi",
"mouse-modes": "Mouse modes", "mouse-modes": "Hiire režiimid",
"selection-mode": "Selection Mode (default)", "selection-mode": "Valikurežiim (vaikeväärtus)",
"selection-mode-desc": "The selection mode is the main mode that can be used to enter data:", "selection-mode-desc": "See valikurežiim on peamine meetod andmete sisestamiseks:",
"selection-mode-steps": { "selection-mode-steps": {
"draw": "Draw a rectangle on the text you want to select.", "draw": "Joonista ristkülik teksti ümber, mida soovid valida.",
"click": "Click on any field on the right and then click back on the rectangle above the image.", "click": "Kliki ükskõik mis parempoolsel väljal ja siis kliki tagasi ristkülikul pildi kohal.",
"result": "The selected text will appear inside the previously selected field." "result": "Valitud tekst ilmub eelnevalt valitud välja sisse."
}, },
"pan-and-zoom-mode": "Pan and Zoom Mode", "pan-and-zoom-mode": "Liigutamise ja zoomimise režiim",
"pan-and-zoom-desc": "Select pan and zoom by clicking the icon. This mode allows to zoom inside the image and move around to make using big images easier.", "pan-and-zoom-desc": "Vali liigutamise ja zoomimise režiim klikkides ikoonile. See režiim võimaldab zoomida pildi sisse ja liikuda ringi, tehes suurte piltide kasutamise lihtsamaks.",
"split-text-mode": "Split Text modes", "split-text-mode": "Teksti poolitamise meetodid",
"split-modes": { "split-modes": {
"line-mode": "Line mode (default)", "line-mode": "Rearežiim (vaikeseade)",
"line-mode-desc": "In line mode, the text will be propagated by keeping the original line breaks. This mode is useful when using bulk add on a list of ingredients where one ingredient is one line.", "line-mode-desc": "Rearežiimis levitatakse teksti, säilitades samal ajal algsed reavahetused. See režiim on kasulik, kui kasutate hulgi-lisamist koostisosade loendis, kus üks koostisosa on üks rida.",
"block-mode": "Block mode", "block-mode": "Ploki režiim",
"block-mode-desc": "In block mode, the text will be split in blocks. This mode is useful when bulk adding instructions that are usually written in paragraphs.", "block-mode-desc": "Ploki režiimis jaotatakse tekst plokkidesse. See režiim on kasulik kui tahad hulgi-lisada instructsioone, mis on tavaliselt kirjutatud paragrahvides.",
"flat-mode": "Flat mode", "flat-mode": "Tasandamise režiim",
"flat-mode-desc": "In flat mode, the text will be added to the selected recipe field with no line breaks." "flat-mode-desc": "Tasandamise režiimis lisatakse tekst valitud retseptile ilma reavahetusteta."
} }
} }
}, },
"admin": { "admin": {
"maintenance": { "maintenance": {
"storage-details": "Storage Details", "storage-details": "Talletamise detailid",
"page-title": "Site Maintenance", "page-title": "Lehekülje hooldus",
"summary-title": "Summary", "summary-title": "Kokkuvõte",
"button-label-get-summary": "Get Summary", "button-label-get-summary": "Saa kokkuvõte",
"button-label-open-details": "Details", "button-label-open-details": "Detailid",
"info-description-data-dir-size": "Data Directory Size", "info-description-data-dir-size": "Andmete kausta suurus",
"info-description-log-file-size": "Log File Size", "info-description-log-file-size": "Logifaili suurus",
"info-description-cleanable-directories": "Cleanable Directories", "info-description-cleanable-directories": "Puhastatavad kaustad",
"info-description-cleanable-images": "Cleanable Images", "info-description-cleanable-images": "Puhastatavad pildid",
"storage": { "storage": {
"title-temporary-directory": "Temporary Directory (.temp)", "title-temporary-directory": "Ajutine kaust (.temp)",
"title-backups-directory": "Backups Directory (backups)", "title-backups-directory": "Tagavarakoopiate kaust (backups)",
"title-groups-directory": "Groups Directory (groups)", "title-groups-directory": "Gruppide kaust (groups)",
"title-recipes-directory": "Recipes Directory (recipes)", "title-recipes-directory": "Retseptide kaust (recipes)",
"title-user-directory": "User Directory (user)" "title-user-directory": "Kasutaja kaust (user)"
}, },
"action-delete-log-files-name": "Delete Log Files", "action-delete-log-files-name": "Kustuta logifailid",
"action-delete-log-files-description": "Deletes all the log files", "action-delete-log-files-description": "Kustutab kõik logifailid",
"action-clean-directories-name": "Clean Directories", "action-clean-directories-name": "Puhasta kaustad",
"action-clean-directories-description": "Removes all the recipe folders that are not valid UUIDs", "action-clean-directories-description": "Eemaldab kõik retseptide kaustad, millel pole kehtivat UUID-d.",
"action-clean-temporary-files-name": "Clean Temporary Files", "action-clean-temporary-files-name": "Kustuta ajutised failid.",
"action-clean-temporary-files-description": "Removes all files and folders in the .temp directory", "action-clean-temporary-files-description": "Eemaldab kõik failid ja kaustad .temp kaustas",
"action-clean-images-name": "Clean Images", "action-clean-images-name": "Puhasta pildid",
"action-clean-images-description": "Removes all the images that don't end with .webp", "action-clean-images-description": "Eemaldab kõik pildid, mis ei lõppe .webp laiendiga",
"actions-description": "Maintenance actions are {destructive_in_bold} and should be used with caution. Performing any of these actions is {irreversible_in_bold}.", "actions-description": "Haldustoimingud on {destructive_in_bold} ning peaks kasutama ettevaatusega. Kõgi nende tegevuste tegemine on {irreversible_in_bold}.",
"actions-description-destructive": "destructive", "actions-description-destructive": "destrucktiivsed",
"actions-description-irreversible": "irreversible", "actions-description-irreversible": "tagasivõetamatu",
"logs-action-refresh": "Refresh Logs", "logs-action-refresh": "Uuenda logisid",
"logs-page-title": "Mealie Logs", "logs-page-title": "Mealie logid",
"logs-tail-lines-label": "Tail Lines" "logs-tail-lines-label": "Piira ridu"
}, },
"mainentance": { "mainentance": {
"actions-title": "Actions" "actions-title": "Tegevused"
}, },
"ingredients-natural-language-processor": "Ingredients Natural Language Processor", "ingredients-natural-language-processor": "Koostisosade loomuliku keele töötlemine",
"ingredients-natural-language-processor-explanation": "Mealie uses Conditional Random Fields (CRFs) for parsing and processing ingredients. The model used for ingredients is based off a data set of over 100,000 ingredients from a dataset compiled by the New York Times. Note that as the model is trained in English only, you may have varied results when using the model in other languages. This page is a playground for testing the model.", "ingredients-natural-language-processor-explanation": "Mealie kasutab koostisosade analüüsimiseks ja töötlemiseks tingimuslikke juhuslikke välju (CRF). Koostisosade jaoks kasutatav mudel põhineb enam kui 100 000 koostisosal New York Timesi koostatud andmekogumist. Pange tähele, et mudelit õpetati ainult inglise keeles, seega võivad tulemused teiste keelte kasutamisel erineda. See leht on mudeli testimise mängumaa.",
"ingredients-natural-language-processor-explanation-2": "It's not perfect, but it yields great results in general and is a good starting point for manually parsing ingredients into individual fields. Alternatively, you can also use the \"Brute\" processor that uses a pattern matching technique to identify ingredients.", "ingredients-natural-language-processor-explanation-2": "See ei ole täiuslik, kuid annab tavaliselt väga häid tulemusi ja on hea algus koostisosade käsitsi määramiseks üksikutele väljadele. Teise võimalusena võite kasutada \"jõulist\" protsessorit, mis kasutab koostisosade tuvastamiseks mustri sobitamise tehnikat.",
"nlp": "NLP", "nlp": "NLP",
"brute": "Brute", "brute": "Jõuline",
"openai": "OpenAI", "openai": "OpenAI",
"show-individual-confidence": "Show individual confidence", "show-individual-confidence": "Näita individuaalseid usalfusväärsusi",
"ingredient-text": "Ingredient Text", "ingredient-text": "Koostisosa tekst",
"average-confident": "{0} Confident", "average-confident": "{0} usaldusväärne",
"try-an-example": "Try an example", "try-an-example": "Proovi näidet",
"parser": "Parser", "parser": "Parser",
"background-tasks": "Background Tasks", "background-tasks": "Taustaülesanded",
"background-tasks-description": "Here you can view all the running background tasks and their status", "background-tasks-description": "Siin sa saad vaadata kõiki jooksvaid taustaülesandeid ja nende staatust",
"no-logs-found": "No Logs Found", "no-logs-found": "Logisid ei leitud",
"tasks": "Tasks", "tasks": "Ülesanded",
"setup": { "setup": {
"first-time-setup": "First Time Setup", "first-time-setup": "Esimese korra seadistus",
"welcome-to-mealie-get-started": "Welcome to Mealie! Let's get started", "welcome-to-mealie-get-started": "Teretulemast Mealie-sse! Alustame",
"already-set-up-bring-to-homepage": "I'm already set up, just bring me to the homepage", "already-set-up-bring-to-homepage": "Ma olen juba vajalikud asjad seadistanud, vii mind pealehele",
"common-settings-for-new-sites": "Here are some common settings for new sites", "common-settings-for-new-sites": "Siin on mõned harilikud sätted uute lehekülgede jaoks",
"setup-complete": "Setup Complete!", "setup-complete": "Seadistus valmis!",
"here-are-a-few-things-to-help-you-get-started": "Here are a few things to help you get started with Mealie", "here-are-a-few-things-to-help-you-get-started": "Siin on mõned asjad mis aitavad sul teha algust Mealie-ga",
"restore-from-v1-backup": "Have a backup from a previous instance of Mealie v1? You can restore it here.", "restore-from-v1-backup": "Kas sul on tagavarakoopia varasemast Mealie v1 instantsist? Sa saad taastada selle siin.",
"manage-profile-or-get-invite-link": "Manage your own profile, or grab an invite link to share with others." "manage-profile-or-get-invite-link": "Halda oma profiili, või haara kutselink teistega jagamiseks."
}, },
"debug-openai-services": "Debug OpenAI Services", "debug-openai-services": "Otsi vigu OpenAI teenustes",
"debug-openai-services-description": "Use this page to debug OpenAI services. You can test your OpenAI connection and see the results here. If you have image services enabled, you can also provide an image.", "debug-openai-services-description": "Kasuta seda lehekülge et otsida vigu OpenAI teenustes. Sa saad testida oma OpenAI ühendust ja näha tulemusi siin. Kui sul on pildi teenused lubatud, võid esitada ka pildi.",
"run-test": "Run Test", "run-test": "Jooksuta testi",
"test-results": "Test Results", "test-results": "Testi tulemused",
"group-delete-note": "Groups with users or households cannot be deleted", "group-delete-note": "Leibkondade või kasutajatega gruppe ei saa kustutada",
"household-delete-note": "Households with users cannot be deleted" "household-delete-note": "Leibkonda koos kasutajatega ei saa kustutada"
}, },
"profile": { "profile": {
"welcome-user": "👋 Welcome, {0}!", "welcome-user": "👋 Tere tulemast, {0}!",
"description": "Manage your profile, recipes, and group settings.", "description": "Halda oma profiili, retsepte ja grupi seadeid.",
"invite-link": "Invite Link", "invite-link": "Kutselink",
"get-invite-link": "Get Invite Link", "get-invite-link": "Saa Kutselink",
"get-public-link": "Get Public Link", "get-public-link": "Saa avalik link",
"account-summary": "Account Summary", "account-summary": "Konto kokkuvõte",
"account-summary-description": "Here's a summary of your group's information.", "account-summary-description": "Siin on kokkuvõte sinu grupi infost",
"group-statistics": "Group Statistics", "group-statistics": "Grupi statistika",
"group-statistics-description": "Your Group Statistics provide some insight how you're using Mealie.", "group-statistics-description": "Sinu grupi statistika heidab pilgu sinu Mealie kasutusmustritesse.",
"household-statistics": "Household Statistics", "household-statistics": "Leibkonna statistika",
"household-statistics-description": "Your Household Statistics provide some insight how you're using Mealie.", "household-statistics-description": "Sinu leibkonna statistika heidab pilgu sinu Mealie kasutusmustritesse.",
"storage-capacity": "Storage Capacity", "storage-capacity": "Salvestusruumi maht",
"storage-capacity-description": "Your storage capacity is a calculation of the images and assets you have uploaded.", "storage-capacity-description": "Sinu salvestusruumi maht on sinu üleslaetud summa piltide ja manuste mahtude summa",
"personal": "Personal", "personal": "Personaalne",
"personal-description": "These are settings that are personal to you. Changes here won't affect other users.", "personal-description": "Need seaded on personaalsed ega mõjuta teisi kasutajaid.",
"user-settings": "User Settings", "user-settings": "Kasutaja Seaded",
"user-settings-description": "Manage your preferences, change your password, and update your email.", "user-settings-description": "Halda oma eelistusi, muuda oma salasõna, ja uuenda oma emaili.",
"api-tokens-description": "Manage your API Tokens for access from external applications.", "api-tokens-description": "Halda oma API identifikaatoreid, et pääseda ligi välistele rakendustele",
"group-description": "These items are shared within your group. Editing one of them will change it for the whole group!", "group-description": "Need üksused on jagatud sinu grupiga. Üksuse muutmine muudab selle kogu grupi jaoks!",
"group-settings": "Group Settings", "group-settings": "Grupi seaded",
"group-settings-description": "Manage your common group settings, like privacy settings.", "group-settings-description": "Halda oma üldiseid grupi sätteid nagu privaatsussätted.",
"household-description": "These items are shared within your household. Editing one of them will change it for the whole household!", "household-description": "Need üksused on jagatud sinu leibkonnaga. Üksuste muutmine muudab selle kogu leibkonna jaoks!",
"household-settings": "Household Settings", "household-settings": "Leibkonna seaded",
"household-settings-description": "Manage your household settings, like mealplan and privacy settings.", "household-settings-description": "Halda oma leibkonna seadeid nagu toitumisplaan ja privaatsussätted.",
"cookbooks-description": "Manage a collection of recipe categories and generate pages for them.", "cookbooks-description": "Halda retsepti kategooriate kollektsiooni ja genereeri nende jaoks leheküljed.",
"members": "Members", "members": "Liikmed",
"members-description": "See who's in your household and manage their permissions.", "members-description": "Vaata oma leibkonna liikmeid ja nende õiguseid..",
"webhooks-description": "Set up webhooks that trigger on days that you have mealplans scheduled.", "webhooks-description": "Seadistage veebihaagid, mis käivituvad päevadel, mil teil on planeeritud söögikava.",
"notifiers": "Notifiers", "notifiers": "Teavitajad",
"notifiers-description": "Set up email and push notifications that trigger on specific events.", "notifiers-description": "Sea üles email ja tõukemärguanded mis käivitatakse kindlatel sündmustel.",
"manage-data": "Manage Data", "manage-data": "Halda andmeid",
"manage-data-description": "Manage your Mealie data; Foods, Units, Categories, Tags and more.", "manage-data-description": "Halda oma Mealie andmeid: toidud, ühikud, kategooriad, sildid ja muu.",
"data-migrations": "Data Migrations", "data-migrations": "Andmete rändlus",
"data-migrations-description": "Migrate your existing data from other applications like Nextcloud Recipes and Chowdown.", "data-migrations-description": "Impordi oma olemasolevad andmed teisest rakendusest nagu Nextcloud retseptid ning Chowdown.",
"email-sent": "Email Sent", "email-sent": "Email saadetud",
"error-sending-email": "Error Sending Email", "error-sending-email": "Tõrge emaili saatmisel",
"personal-information": "Personal Information", "personal-information": "Personaalne informatsioon",
"preferences": "Preferences", "preferences": "Eelistused",
"show-advanced-description": "Show advanced features (API Keys, Webhooks, and Data Management)", "show-advanced-description": "Näita edasisi funktsioone (API võtmed, veebihaagid, ja andmehaldus)",
"back-to-profile": "Back to Profile", "back-to-profile": "Tagasi profiilile",
"looking-for-privacy-settings": "Looking for Privacy Settings?", "looking-for-privacy-settings": "Otsid privaatsussätteid?",
"manage-your-api-tokens": "Manage Your API Tokens", "manage-your-api-tokens": "Halda oma API identifikaatoreid",
"manage-user-profile": "Manage User Profile", "manage-user-profile": "Halda kasutaja profiili",
"manage-cookbooks": "Manage Cookbooks", "manage-cookbooks": "Halda kokaraamatuid",
"manage-members": "Manage Members", "manage-members": "Halda liikmeid",
"manage-webhooks": "Manage Webhooks", "manage-webhooks": "Halda veebihaake",
"manage-notifiers": "Manage Notifiers", "manage-notifiers": "Halda teavitajaid",
"manage-data-migrations": "Manage Data Migrations" "manage-data-migrations": "Halda andmete migratsioone"
}, },
"cookbook": { "cookbook": {
"cookbooks": "Cookbooks", "cookbooks": "Kokaraamatud",
"description": "Cookbooks are another way to organize recipes by creating cross sections of recipes, organizers, and other filters. Creating a cookbook will add an entry to the side-bar and all the recipes with the filters chosen will be displayed in the cookbook.", "description": "Kokaraamatud on veel üks viis retseptide korraldamiseks, luues erinevaid filtreid. Kokaraamatu loomisel lisatakse külgribale kirje ja kõik retseptid, mis vastavad valitud filtritele, ilmuvad kokaraamatusse.",
"hide-cookbooks-from-other-households": "Hide Cookbooks from Other Households", "hide-cookbooks-from-other-households": "Peida kokaraamatud teiste leibkondade eest",
"hide-cookbooks-from-other-households-description": "When enabled, only cookbooks from your household will appear on the sidebar", "hide-cookbooks-from-other-households-description": "Kui lubatud, ainult sinu leibkonna kokaraamatud ilmuvad külgribale",
"public-cookbook": "Public Cookbook", "public-cookbook": "Avalik kokaraamat",
"public-cookbook-description": "Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page.", "public-cookbook-description": "Avalikke kokaraamatuid saab jagada Mealie-väliste kasutajatega. Neid näidatakse grupi leheküljel.",
"filter-options": "Filter Options", "filter-options": "Filtreerimisseaded",
"filter-options-description": "When require all is selected the cookbook will only include recipes that have all of the items selected. This applies to each subset of selectors and not a cross section of the selected items.", "filter-options-description": "Kui valitud on \"Nõua kõike\", siis valitud kokaraamat sisaldab ainult retsepte, mis sisaldavad kõiki valitud üksusi. See kehtib valijate iga alamhulga, mitte valitud üksuste üleselt.",
"require-all-categories": "Require All Categories", "require-all-categories": "Nõua kõik kategooriad",
"require-all-tags": "Require All Tags", "require-all-tags": "Nõua kõik sildid",
"require-all-tools": "Require All Tools", "require-all-tools": "Nõua kõik tööriistad",
"cookbook-name": "Cookbook Name", "cookbook-name": "Kokaraamatu nimi",
"cookbook-with-name": "Cookbook {0}", "cookbook-with-name": "Kokaraamat {0}",
"household-cookbook-name": "{0} Cookbook {1}", "household-cookbook-name": "{0} Kokaraamat {1}",
"create-a-cookbook": "Create a Cookbook", "create-a-cookbook": "Loo kokaraamat",
"cookbook": "Cookbook" "cookbook": "Kokaraamat"
}, },
"query-filter": { "query-filter": {
"logical-operators": { "logical-operators": {
"and": "AND", "and": "JA",
"or": "OR" "or": "VÕI"
}, },
"relational-operators": { "relational-operators": {
"equals": "equals", "equals": "võrdub",
"does-not-equal": "does not equal", "does-not-equal": "ei võrdu",
"is-greater-than": "is greater than", "is-greater-than": "on suurem kui",
"is-greater-than-or-equal-to": "is greater than or equal to", "is-greater-than-or-equal-to": "on suurem või võrdne kui",
"is-less-than": "is less than", "is-less-than": "on vähem kui",
"is-less-than-or-equal-to": "is less than or equal to" "is-less-than-or-equal-to": "on väiksem või võrdne kui"
}, },
"relational-keywords": { "relational-keywords": {
"is": "is", "is": "on",
"is-not": "is not", "is-not": "ei ole",
"is-one-of": "is one of", "is-one-of": "on üks",
"is-not-one-of": "is not one of", "is-not-one-of": "ei ole üks",
"contains-all-of": "contains all of", "contains-all-of": "sisaldab kõiki",
"is-like": "is like", "is-like": "on nagu",
"is-not-like": "is not like" "is-not-like": "ei ole nagu"
} }
} }
} }

View file

@ -8,7 +8,7 @@
"database-type": "Databasetype", "database-type": "Databasetype",
"database-url": "URL til database", "database-url": "URL til database",
"default-group": "Standardgruppe", "default-group": "Standardgruppe",
"default-household": "Standard hushold", "default-household": "Standard husholdning",
"demo": "Demo", "demo": "Demo",
"demo-status": "Demostatus", "demo-status": "Demostatus",
"development": "Utvikling", "development": "Utvikling",
@ -51,7 +51,7 @@
"category": "Kategori" "category": "Kategori"
}, },
"events": { "events": {
"apprise-url": "Apprise URL", "apprise-url": "Apprise-URL",
"database": "Database", "database": "Database",
"delete-event": "Slett hendelse", "delete-event": "Slett hendelse",
"event-delete-confirmation": "Er du sikker på at du ønsker å slette denne hendelsen?", "event-delete-confirmation": "Er du sikker på at du ønsker å slette denne hendelsen?",
@ -66,9 +66,9 @@
"subscribed-events": "Abonnerte hendelser", "subscribed-events": "Abonnerte hendelser",
"test-message-sent": "Testmelding sendt", "test-message-sent": "Testmelding sendt",
"message-sent": "Melding sendt", "message-sent": "Melding sendt",
"new-notification": "Ny varsel", "new-notification": "Nytt varsel",
"event-notifiers": "Hendelsesvarsler", "event-notifiers": "Hendelsesvarsler",
"apprise-url-skipped-if-blank": "Apprise URL (hoppes over hvis tom)", "apprise-url-skipped-if-blank": "Apprise-URL (hoppes over hvis tom)",
"enable-notifier": "Aktiver varslingsagenten", "enable-notifier": "Aktiver varslingsagenten",
"what-events": "Hvilke hendelser skal denne varslingsagenten abonnere på?", "what-events": "Hvilke hendelser skal denne varslingsagenten abonnere på?",
"user-events": "Brukerhendelser", "user-events": "Brukerhendelser",
@ -87,7 +87,7 @@
"clear": "Tøm", "clear": "Tøm",
"close": "Lukk", "close": "Lukk",
"confirm": "Bekreft", "confirm": "Bekreft",
"confirm-how-does-everything-look": "Hvordan ser alt ut?", "confirm-how-does-everything-look": "Hvordan ser ting ut?",
"confirm-delete-generic": "Er du sikker på at du vil slette denne?", "confirm-delete-generic": "Er du sikker på at du vil slette denne?",
"copied_message": "Kopiert!", "copied_message": "Kopiert!",
"create": "Opprett", "create": "Opprett",
@ -148,7 +148,7 @@
"share": "Del", "share": "Del",
"show-all": "Vis alle", "show-all": "Vis alle",
"shuffle": "Tilfeldig rekkefølge", "shuffle": "Tilfeldig rekkefølge",
"sort": "Sortér", "sort": "Sorter",
"sort-ascending": "Sorter stigende", "sort-ascending": "Sorter stigende",
"sort-descending": "Sortere synkende", "sort-descending": "Sortere synkende",
"sort-alphabetically": "Alfabetisk", "sort-alphabetically": "Alfabetisk",
@ -193,7 +193,7 @@
"confirm-delete-own-admin-account": "Vær oppmerksom på at du holder på å slette din egen administrator-konto! Dette kan ikke angres og vil slette kontoen din permanent!", "confirm-delete-own-admin-account": "Vær oppmerksom på at du holder på å slette din egen administrator-konto! Dette kan ikke angres og vil slette kontoen din permanent!",
"organizer": "Organisator", "organizer": "Organisator",
"transfer": "Overfør", "transfer": "Overfør",
"copy": "Kopiér", "copy": "Kopier",
"color": "Farge", "color": "Farge",
"timestamp": "Tidsstempel", "timestamp": "Tidsstempel",
"last-made": "Sist laget", "last-made": "Sist laget",
@ -281,25 +281,25 @@
}, },
"household": { "household": {
"household": "Husholdning", "household": "Husholdning",
"households": "Husholdning", "households": "Husholdninger",
"user-household": "Brukers husholdning", "user-household": "Brukers husholdning",
"create-household": "Opprett husholdning", "create-household": "Opprett husholdning",
"household-name": "Husholdningenes navn", "household-name": "Husholdningens navn",
"household-group": "Husholdningenes gruppe", "household-group": "Husholdningens gruppe",
"household-management": "Administrer husholdninger", "household-management": "Administrering av husholdninger",
"manage-households": "Administrer husholdninger", "manage-households": "Administrer husholdninger",
"admin-household-management": "Admin husholdningsadministrasjon", "admin-household-management": "Admin husholdningsadministrasjon",
"admin-household-management-text": "Endringer i denne husholdningen vil umiddelbart gjelde.", "admin-household-management-text": "Endringer i denne husholdningen vil umiddelbart gjelde.",
"household-id-value": "Husholdningenes id: {0}", "household-id-value": "Husholdningens id: {0}",
"private-household": "Privat husholdning", "private-household": "Privat husholdning",
"private-household-description": "Setting your household to private will disable all public view options. This overrides any individual public view settings", "private-household-description": "Setting your household to private will disable all public view options. This overrides any individual public view settings",
"lock-recipe-edits-from-other-households": "Lock recipe edits from other households", "lock-recipe-edits-from-other-households": "Lås redigering av oppskrifter fra andre husholdninger",
"lock-recipe-edits-from-other-households-description": "When enabled only users in your household can edit recipes created by your household", "lock-recipe-edits-from-other-households-description": "When enabled only users in your household can edit recipes created by your household",
"household-recipe-preferences": "Husholdningenes oppskriftsinnstillinger", "household-recipe-preferences": "Husholdningenes oppskriftsinnstillinger",
"default-recipe-preferences-description": "Dette er standardinnstillingene når en ny oppskrift blir opprettet i din husholdning. Disse kan endres for individuelle oppskrifter i oppskrifters innstillinger.", "default-recipe-preferences-description": "Dette er standardinnstillingene når en ny oppskrift blir opprettet i din husholdning. Disse kan endres for individuelle oppskrifter i oppskrifters innstillinger.",
"allow-users-outside-of-your-household-to-see-your-recipes": "Tillat brukere utenfor din husholdning å se oppskriftene dine", "allow-users-outside-of-your-household-to-see-your-recipes": "Tillat brukere utenfor din husholdning å se oppskriftene dine",
"allow-users-outside-of-your-household-to-see-your-recipes-description": "Når aktivert, kan du bruke en offentlig lenke for å dele spesifikke oppskrifter uten å autorisere brukeren. Når deaktivert, kan du kun dele oppskrifter med brukere som er i husholdningen din eller med en forhåndsgenerert privat lenke", "allow-users-outside-of-your-household-to-see-your-recipes-description": "Når aktivert, kan du bruke en offentlig lenke for å dele spesifikke oppskrifter uten å autorisere brukeren. Når deaktivert, kan du kun dele oppskrifter med brukere som er i husholdningen din eller med en forhåndsgenerert privat lenke",
"household-preferences": "Husholdnings innstillinger" "household-preferences": "Innstillinger for husholdning"
}, },
"meal-plan": { "meal-plan": {
"create-a-new-meal-plan": "Opprett en ny måltidsplan", "create-a-new-meal-plan": "Opprett en ny måltidsplan",
@ -313,12 +313,12 @@
"main": "Hovedrett", "main": "Hovedrett",
"meal-planner": "Måltidsplanlegger", "meal-planner": "Måltidsplanlegger",
"meal-plans": "Måltidsplaner", "meal-plans": "Måltidsplaner",
"mealplan-categories": "MÅLTIDSPLANKATEGORIER", "mealplan-categories": "MÅLTIDSPLAN-KATEGORIER",
"mealplan-created": "Måltidsplan opprettet", "mealplan-created": "Måltidsplan opprettet",
"mealplan-creation-failed": "Opprettelse av måltidsplan mislyktes", "mealplan-creation-failed": "Opprettelse av måltidsplan mislyktes",
"mealplan-deleted": "Måltidsplan slettet", "mealplan-deleted": "Måltidsplan slettet",
"mealplan-deletion-failed": "Sletting av måltidsplan mislyktes", "mealplan-deletion-failed": "Sletting av måltidsplan mislyktes",
"mealplan-settings": "Måltidsplaninnstillinger", "mealplan-settings": "Innstillinger for måltidsplan",
"mealplan-update-failed": "Oppdatering av måltidsplan mislyktes", "mealplan-update-failed": "Oppdatering av måltidsplan mislyktes",
"mealplan-updated": "Måltidsplan oppdatert", "mealplan-updated": "Måltidsplan oppdatert",
"mealplan-households-description": "If no household is selected, recipes can be added from any household", "mealplan-households-description": "If no household is selected, recipes can be added from any household",
@ -426,7 +426,7 @@
"error-details": "Bare nettsteder som inneholder ld+json eller mikrodata kan importeres av Mealie. De største oppskriftsnettstedene støtter denne datastrukturen. Dersom nettstedet ditt ikke kan importeres, men det er json-data i loggen, må du rapportere et GitHub-problem med nettadressen og data.", "error-details": "Bare nettsteder som inneholder ld+json eller mikrodata kan importeres av Mealie. De største oppskriftsnettstedene støtter denne datastrukturen. Dersom nettstedet ditt ikke kan importeres, men det er json-data i loggen, må du rapportere et GitHub-problem med nettadressen og data.",
"error-title": "Ser ut til at ingenting ble funnet", "error-title": "Ser ut til at ingenting ble funnet",
"from-url": "Importer en oppskrift", "from-url": "Importer en oppskrift",
"github-issues": "GitHub-problemer", "github-issues": "GitHub Issues",
"google-ld-json-info": "Google ld+json-informasjon", "google-ld-json-info": "Google ld+json-informasjon",
"must-be-a-valid-url": "Må være en gyldig nettadresse", "must-be-a-valid-url": "Må være en gyldig nettadresse",
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Lim inn oppskriftsdataene. Hver linje blir behandlet som et element i en liste", "paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Lim inn oppskriftsdataene. Hver linje blir behandlet som et element i en liste",
@ -467,7 +467,7 @@
"calories-suffix": "kalorier", "calories-suffix": "kalorier",
"carbohydrate-content": "Karbohydrater", "carbohydrate-content": "Karbohydrater",
"categories": "Kategorier", "categories": "Kategorier",
"cholesterol-content": "Cholesterol", "cholesterol-content": "Kolesterol",
"comment-action": "Kommenter", "comment-action": "Kommenter",
"comment": "Kommentar", "comment": "Kommentar",
"comments": "Kommentarer", "comments": "Kommentarer",
@ -514,7 +514,7 @@
"recipe-updated": "Oppskrift oppdatert", "recipe-updated": "Oppskrift oppdatert",
"remove-from-favorites": "Fjern fra favoritter", "remove-from-favorites": "Fjern fra favoritter",
"remove-section": "Fjern seksjon", "remove-section": "Fjern seksjon",
"saturated-fat-content": "Saturated fat", "saturated-fat-content": "Mettet fett",
"save-recipe-before-use": "Lagre oppskrift før bruk", "save-recipe-before-use": "Lagre oppskrift før bruk",
"section-title": "Seksjonstittel", "section-title": "Seksjonstittel",
"servings": "Porsjoner", "servings": "Porsjoner",
@ -526,9 +526,9 @@
"sugar-content": "Sukker", "sugar-content": "Sukker",
"title": "Tittel", "title": "Tittel",
"total-time": "Total tid", "total-time": "Total tid",
"trans-fat-content": "Trans-fat", "trans-fat-content": "Transfett",
"unable-to-delete-recipe": "Kan ikke slette oppskrift", "unable-to-delete-recipe": "Kan ikke slette oppskrift",
"unsaturated-fat-content": "Unsaturated fat", "unsaturated-fat-content": "Umettet fett",
"no-recipe": "Ingen oppskrift", "no-recipe": "Ingen oppskrift",
"locked-by-owner": "Låst av eier", "locked-by-owner": "Låst av eier",
"join-the-conversation": "Delta i samtalen", "join-the-conversation": "Delta i samtalen",
@ -547,7 +547,7 @@
"failed-to-add-recipe-to-mealplan": "Klarte ikke å legge til oppskrift i måltidsplan", "failed-to-add-recipe-to-mealplan": "Klarte ikke å legge til oppskrift i måltidsplan",
"failed-to-add-to-list": "Klarte ikke å legge til oppskrift i listen", "failed-to-add-to-list": "Klarte ikke å legge til oppskrift i listen",
"yield": "Gir", "yield": "Gir",
"yields-amount-with-text": "Yields {amount} {text}", "yields-amount-with-text": "Gir {amount} {text}",
"yield-text": "Yield Text", "yield-text": "Yield Text",
"quantity": "Antall", "quantity": "Antall",
"choose-unit": "Velg enhet", "choose-unit": "Velg enhet",
@ -576,10 +576,10 @@
"timeline-no-events-found-try-adjusting-filters": "Ingen hendelser funnet. Prøv å endre søkefiltrene.", "timeline-no-events-found-try-adjusting-filters": "Ingen hendelser funnet. Prøv å endre søkefiltrene.",
"group-global-timeline": "{groupName} Global tidslinje", "group-global-timeline": "{groupName} Global tidslinje",
"open-timeline": "Åpne tidslinje", "open-timeline": "Åpne tidslinje",
"made-this": "Jeg har laget denne", "made-this": "Jeg har laget dette",
"how-did-it-turn-out": "Hvordan ble den?", "how-did-it-turn-out": "Hvordan ble det?",
"user-made-this": "{user} har laget denne", "user-made-this": "{user} har laget dette",
"last-made-date": "Sist laget: {date}", "last-made-date": "Sist laget {date}",
"api-extras-description": "Ekstramaterialer til oppskrifter er en viktig funksjon i Mealie API-en. De lar deg opprette egendefinerte JSON-nøkkel/verdi-par innenfor en oppskrift for å referere fra tredjepartsapplikasjoner. Du kan bruke disse nøklene til å gi informasjon for eksempel for å utløse automatiseringer eller egendefinerte meldinger som skal videreformidles til ønsket enhet.", "api-extras-description": "Ekstramaterialer til oppskrifter er en viktig funksjon i Mealie API-en. De lar deg opprette egendefinerte JSON-nøkkel/verdi-par innenfor en oppskrift for å referere fra tredjepartsapplikasjoner. Du kan bruke disse nøklene til å gi informasjon for eksempel for å utløse automatiseringer eller egendefinerte meldinger som skal videreformidles til ønsket enhet.",
"message-key": "Meldingsnøkkel", "message-key": "Meldingsnøkkel",
"parse": "Analyser", "parse": "Analyser",
@ -610,7 +610,7 @@
"scrape-recipe": "Skrap oppskrift", "scrape-recipe": "Skrap oppskrift",
"scrape-recipe-description": "Skrap en oppskrift ved bruk av nettadresse. Oppgi nettadressen til nettstedet du vil skrape, så vil Mealie forsøke å skrape oppskriften fra den siden og legge den til i samlingen din.", "scrape-recipe-description": "Skrap en oppskrift ved bruk av nettadresse. Oppgi nettadressen til nettstedet du vil skrape, så vil Mealie forsøke å skrape oppskriften fra den siden og legge den til i samlingen din.",
"scrape-recipe-have-a-lot-of-recipes": "Har du mange oppskrifter du ønsker å skrape samtidig?", "scrape-recipe-have-a-lot-of-recipes": "Har du mange oppskrifter du ønsker å skrape samtidig?",
"scrape-recipe-suggest-bulk-importer": "Prøv masseimporten", "scrape-recipe-suggest-bulk-importer": "Prøv masseimportering",
"scrape-recipe-have-raw-html-or-json-data": "Have raw HTML or JSON data?", "scrape-recipe-have-raw-html-or-json-data": "Have raw HTML or JSON data?",
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly", "scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
"import-original-keywords-as-tags": "Importer originale søkeord som emneord", "import-original-keywords-as-tags": "Importer originale søkeord som emneord",
@ -637,7 +637,7 @@
"recipe-debugger-use-openai-description": "Bruke OpenAI til å analysere resultatene i stedet for å basere seg på scraper-biblioteket. Når du oppretter en oppskrift via URL, blir dette gjort automatisk hvis scraper-biblioteket mislykkes, men du kan teste det manuelt her.", "recipe-debugger-use-openai-description": "Bruke OpenAI til å analysere resultatene i stedet for å basere seg på scraper-biblioteket. Når du oppretter en oppskrift via URL, blir dette gjort automatisk hvis scraper-biblioteket mislykkes, men du kan teste det manuelt her.",
"debug": "Feilsøk", "debug": "Feilsøk",
"tree-view": "Trevisning", "tree-view": "Trevisning",
"recipe-servings": "Recipe Servings", "recipe-servings": "Oppskriftsporsjoner",
"recipe-yield": "Utbytte av oppskrift", "recipe-yield": "Utbytte av oppskrift",
"recipe-yield-text": "Recipe Yield Text", "recipe-yield-text": "Recipe Yield Text",
"unit": "Enhet", "unit": "Enhet",
@ -648,7 +648,7 @@
"recipe-actions": "Oppskriftshandlinger", "recipe-actions": "Oppskriftshandlinger",
"parser": { "parser": {
"experimental-alert-text": "Mealie bruker naturlig språkbehandling til å analysere og lage enheter og matvarer til oppskriftsingrediensene dine. Denne funksjonen er eksperimentell og fungerer kanskje ikke som forventet. Hvis du foretrekker ikke å bruke de foreslåtte resultatene, kan du velge 'Avbryt', og endringene dine vil ikke bli lagret.", "experimental-alert-text": "Mealie bruker naturlig språkbehandling til å analysere og lage enheter og matvarer til oppskriftsingrediensene dine. Denne funksjonen er eksperimentell og fungerer kanskje ikke som forventet. Hvis du foretrekker ikke å bruke de foreslåtte resultatene, kan du velge 'Avbryt', og endringene dine vil ikke bli lagret.",
"ingredient-parser": "Ingrediens-parser", "ingredient-parser": "Ingrediens-analyserer",
"explanation": "For å bruke ingrediens-forslag, klikk på 'Analyser alle' for å starte prosessen. Når de prosesserte ingrediensene er tilgjengelige, kan du se gjennom elementene og kontrollere at de er sjekket korrekt. Modellens tillitsverdi vises på høyre side av elementet. Denne scoren er et gjennomsnitt av alle de individuelle poengene og alltid er ikke helt nøyaktige.", "explanation": "For å bruke ingrediens-forslag, klikk på 'Analyser alle' for å starte prosessen. Når de prosesserte ingrediensene er tilgjengelige, kan du se gjennom elementene og kontrollere at de er sjekket korrekt. Modellens tillitsverdi vises på høyre side av elementet. Denne scoren er et gjennomsnitt av alle de individuelle poengene og alltid er ikke helt nøyaktige.",
"alerts-explainer": "Varsler vil bli vist dersom en matchende matvare eller enhet blir funnet, men ikke finnes i databasen.", "alerts-explainer": "Varsler vil bli vist dersom en matchende matvare eller enhet blir funnet, men ikke finnes i databasen.",
"select-parser": "Velg analyserer", "select-parser": "Velg analyserer",
@ -661,25 +661,25 @@
"missing-food": "Opprett manglende mat: {food}", "missing-food": "Opprett manglende mat: {food}",
"no-food": "Ingen matvarer" "no-food": "Ingen matvarer"
}, },
"reset-servings-count": "Reset Servings Count", "reset-servings-count": "Nullstill antall porsjoner",
"not-linked-ingredients": "Additional Ingredients" "not-linked-ingredients": "Tilleggsingredienser"
}, },
"recipe-finder": { "recipe-finder": {
"recipe-finder": "Recipe Finder", "recipe-finder": "Oppskriftsfinner",
"recipe-finder-description": "Search for recipes based on ingredients you have on hand. You can also filter by tools you have available, and set a maximum number of missing ingredients or tools.", "recipe-finder-description": "Search for recipes based on ingredients you have on hand. You can also filter by tools you have available, and set a maximum number of missing ingredients or tools.",
"selected-ingredients": "Selected Ingredients", "selected-ingredients": "Velg ingredienser",
"no-ingredients-selected": "No ingredients selected", "no-ingredients-selected": "Ingen ingredienser valgt",
"missing": "Missing", "missing": "Mangler",
"no-recipes-found": "No recipes found", "no-recipes-found": "Ingen oppskrifter funnet",
"no-recipes-found-description": "Try adding more ingredients to your search or adjusting your filters", "no-recipes-found-description": "Try adding more ingredients to your search or adjusting your filters",
"include-ingredients-on-hand": "Include Ingredients On Hand", "include-ingredients-on-hand": "Include Ingredients On Hand",
"include-tools-on-hand": "Include Tools On Hand", "include-tools-on-hand": "Include Tools On Hand",
"max-missing-ingredients": "Max Missing Ingredients", "max-missing-ingredients": "Maks antall manglende ingredienser",
"max-missing-tools": "Max Missing Tools", "max-missing-tools": "Maks antall manglende redskaper",
"selected-tools": "Selected Tools", "selected-tools": "Velg redskaper",
"other-filters": "Other Filters", "other-filters": "Andre filtre",
"ready-to-make": "Ready to Make", "ready-to-make": "Klar til å lages",
"almost-ready-to-make": "Almost Ready to Make" "almost-ready-to-make": "Nesten klar til å lages"
}, },
"search": { "search": {
"advanced-search": "Avansert søk", "advanced-search": "Avansert søk",
@ -884,7 +884,7 @@
"are-you-sure-you-want-to-check-all-items": "Er du sikker på at du vil velge alle elementer?", "are-you-sure-you-want-to-check-all-items": "Er du sikker på at du vil velge alle elementer?",
"are-you-sure-you-want-to-uncheck-all-items": "Er du sikker på at du vil fjerne valg av alle elementer?", "are-you-sure-you-want-to-uncheck-all-items": "Er du sikker på at du vil fjerne valg av alle elementer?",
"are-you-sure-you-want-to-delete-checked-items": "Er du sikker på at du vil slette alle valgte elementer?", "are-you-sure-you-want-to-delete-checked-items": "Er du sikker på at du vil slette alle valgte elementer?",
"no-shopping-lists-found": "No Shopping Lists Found" "no-shopping-lists-found": "Ingen handlelister funnet"
}, },
"sidebar": { "sidebar": {
"all-recipes": "Alle oppskrifter", "all-recipes": "Alle oppskrifter",
@ -1286,17 +1286,17 @@
"restore-from-v1-backup": "Har du en sikkerhetskopi fra en tidligere forekomst av Mealie v1? Du kan gjenopprette den her.", "restore-from-v1-backup": "Har du en sikkerhetskopi fra en tidligere forekomst av Mealie v1? Du kan gjenopprette den her.",
"manage-profile-or-get-invite-link": "Administrer din egen profil, eller hent en invitasjonslenke for å dele med andre." "manage-profile-or-get-invite-link": "Administrer din egen profil, eller hent en invitasjonslenke for å dele med andre."
}, },
"debug-openai-services": "Debug OpenAI Services", "debug-openai-services": "Feilsøk OpenAI-tjenester",
"debug-openai-services-description": "Use this page to debug OpenAI services. You can test your OpenAI connection and see the results here. If you have image services enabled, you can also provide an image.", "debug-openai-services-description": "Use this page to debug OpenAI services. You can test your OpenAI connection and see the results here. If you have image services enabled, you can also provide an image.",
"run-test": "Run Test", "run-test": "Kjør test",
"test-results": "Test Results", "test-results": "Testresultater",
"group-delete-note": "Groups with users or households cannot be deleted", "group-delete-note": "Groups with users or households cannot be deleted",
"household-delete-note": "Households with users cannot be deleted" "household-delete-note": "Households with users cannot be deleted"
}, },
"profile": { "profile": {
"welcome-user": "Velkommen, {0}!", "welcome-user": "Velkommen, {0}!",
"description": "Administrer din profil, oppskrifter og gruppeinnstillinger.", "description": "Administrer din profil, oppskrifter og gruppeinnstillinger.",
"invite-link": "Invite Link", "invite-link": "Invitasjonslenke",
"get-invite-link": "Få invitasjonslenke", "get-invite-link": "Få invitasjonslenke",
"get-public-link": "Få offentlig lenke", "get-public-link": "Få offentlig lenke",
"account-summary": "Kontosammendrag", "account-summary": "Kontosammendrag",
@ -1363,25 +1363,25 @@
}, },
"query-filter": { "query-filter": {
"logical-operators": { "logical-operators": {
"and": "AND", "and": "OG",
"or": "OR" "or": "ELLER"
}, },
"relational-operators": { "relational-operators": {
"equals": "equals", "equals": "er lik",
"does-not-equal": "does not equal", "does-not-equal": "er ikke lik",
"is-greater-than": "is greater than", "is-greater-than": "er større enn",
"is-greater-than-or-equal-to": "is greater than or equal to", "is-greater-than-or-equal-to": "er større enn eller lik",
"is-less-than": "is less than", "is-less-than": "er mindre enn",
"is-less-than-or-equal-to": "is less than or equal to" "is-less-than-or-equal-to": "er mindre enn eller lik"
}, },
"relational-keywords": { "relational-keywords": {
"is": "is", "is": "er",
"is-not": "is not", "is-not": "er ikke",
"is-one-of": "is one of", "is-one-of": "er en av",
"is-not-one-of": "is not one of", "is-not-one-of": "er ikke en av",
"contains-all-of": "contains all of", "contains-all-of": "inneholder alle",
"is-like": "is like", "is-like": "er som",
"is-not-like": "is not like" "is-not-like": "er ikke som"
} }
} }
} }

View file

@ -518,7 +518,7 @@
"save-recipe-before-use": "Zapisz przepis przed użyciem", "save-recipe-before-use": "Zapisz przepis przed użyciem",
"section-title": "Tytuł rozdziału", "section-title": "Tytuł rozdziału",
"servings": "Porcje", "servings": "Porcje",
"serves-amount": "{amount} porcji", "serves-amount": "Porcje {amount}",
"share-recipe-message": "Chcę podzielić się z Tobą moim przepisem na {0}.", "share-recipe-message": "Chcę podzielić się z Tobą moim przepisem na {0}.",
"show-nutrition-values": "Pokaż wartości odżywcze", "show-nutrition-values": "Pokaż wartości odżywcze",
"sodium-content": "Sód", "sodium-content": "Sód",

View file

@ -4,7 +4,7 @@
"about-mealie": "Despre Mealie", "about-mealie": "Despre Mealie",
"api-docs": "Documentație API", "api-docs": "Documentație API",
"api-port": "API Port", "api-port": "API Port",
"application-mode": "Application Mode", "application-mode": "Mod Aplicație",
"database-type": "Tipul bazei de date", "database-type": "Tipul bazei de date",
"database-url": "URL bază de date", "database-url": "URL bază de date",
"default-group": "Grup implicit", "default-group": "Grup implicit",
@ -890,7 +890,7 @@
"all-recipes": "Toate reţetele", "all-recipes": "Toate reţetele",
"backups": "Copii de rezervă", "backups": "Copii de rezervă",
"categories": "Categorii", "categories": "Categorii",
"cookbooks": "Cărți de rețete", "cookbooks": "Cărţi de bucate",
"dashboard": "Panou de control", "dashboard": "Panou de control",
"home-page": "Pagina principală", "home-page": "Pagina principală",
"manage-users": "Gestionare utilizatori", "manage-users": "Gestionare utilizatori",

View file

@ -161,7 +161,7 @@ export interface RecipeTool {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
onHand?: boolean; householdsWithTool?: string[];
[k: string]: unknown; [k: string]: unknown;
} }
export interface CustomPageImport { export interface CustomPageImport {

View file

@ -97,7 +97,7 @@ export interface RecipeTool {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
onHand?: boolean; householdsWithTool?: string[];
[k: string]: unknown; [k: string]: unknown;
} }
export interface SaveCookBook { export interface SaveCookBook {

View file

@ -208,6 +208,27 @@ export interface ReadWebhook {
householdId: string; householdId: string;
id: string; id: string;
} }
export interface HouseholdRecipeBase {
lastMade?: string | null;
}
export interface HouseholdRecipeCreate {
lastMade?: string | null;
householdId: string;
recipeId: string;
}
export interface HouseholdRecipeOut {
lastMade?: string | null;
householdId: string;
recipeId: string;
id: string;
}
export interface HouseholdRecipeSummary {
lastMade?: string | null;
recipeId: string;
}
export interface HouseholdRecipeUpdate {
lastMade?: string | null;
}
export interface HouseholdSave { export interface HouseholdSave {
groupId: string; groupId: string;
name: string; name: string;
@ -297,7 +318,6 @@ export interface IngredientUnit {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
fraction?: boolean; fraction?: boolean;
abbreviation?: string; abbreviation?: string;
pluralAbbreviation?: string | null; pluralAbbreviation?: string | null;
@ -318,7 +338,6 @@ export interface CreateIngredientUnit {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
fraction?: boolean; fraction?: boolean;
abbreviation?: string; abbreviation?: string;
pluralAbbreviation?: string | null; pluralAbbreviation?: string | null;
@ -338,9 +357,9 @@ export interface IngredientFood {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
labelId?: string | null; labelId?: string | null;
aliases?: IngredientFoodAlias[]; aliases?: IngredientFoodAlias[];
householdsWithIngredientFood?: string[];
label?: MultiPurposeLabelSummary | null; label?: MultiPurposeLabelSummary | null;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
@ -363,9 +382,9 @@ export interface CreateIngredientFood {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
labelId?: string | null; labelId?: string | null;
aliases?: CreateIngredientFoodAlias[]; aliases?: CreateIngredientFoodAlias[];
householdsWithIngredientFood?: string[];
[k: string]: unknown; [k: string]: unknown;
} }
export interface CreateIngredientFoodAlias { export interface CreateIngredientFoodAlias {
@ -592,7 +611,7 @@ export interface RecipeTool {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
onHand?: boolean; householdsWithTool?: string[];
[k: string]: unknown; [k: string]: unknown;
} }
export interface ShoppingListRemoveRecipeParams { export interface ShoppingListRemoveRecipeParams {

View file

@ -117,7 +117,7 @@ export interface RecipeTool {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
onHand?: boolean; householdsWithTool?: string[];
[k: string]: unknown; [k: string]: unknown;
} }
export interface SavePlanEntry { export interface SavePlanEntry {

View file

@ -64,9 +64,9 @@ export interface CreateIngredientFood {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
labelId?: string | null; labelId?: string | null;
aliases?: CreateIngredientFoodAlias[]; aliases?: CreateIngredientFoodAlias[];
householdsWithIngredientFood?: string[];
} }
export interface CreateIngredientFoodAlias { export interface CreateIngredientFoodAlias {
name: string; name: string;
@ -79,7 +79,6 @@ export interface CreateIngredientUnit {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
fraction?: boolean; fraction?: boolean;
abbreviation?: string; abbreviation?: string;
pluralAbbreviation?: string | null; pluralAbbreviation?: string | null;
@ -136,9 +135,9 @@ export interface IngredientFood {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
labelId?: string | null; labelId?: string | null;
aliases?: IngredientFoodAlias[]; aliases?: IngredientFoodAlias[];
householdsWithIngredientFood?: string[];
label?: MultiPurposeLabelSummary | null; label?: MultiPurposeLabelSummary | null;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
@ -167,7 +166,6 @@ export interface IngredientUnit {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
fraction?: boolean; fraction?: boolean;
abbreviation?: string; abbreviation?: string;
pluralAbbreviation?: string | null; pluralAbbreviation?: string | null;
@ -262,7 +260,7 @@ export interface RecipeTool {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
onHand?: boolean; householdsWithTool?: string[];
} }
export interface RecipeStep { export interface RecipeStep {
id?: string | null; id?: string | null;
@ -447,24 +445,24 @@ export interface RecipeTimelineEventUpdate {
} }
export interface RecipeToolCreate { export interface RecipeToolCreate {
name: string; name: string;
onHand?: boolean; householdsWithTool?: string[];
} }
export interface RecipeToolOut { export interface RecipeToolOut {
name: string; name: string;
onHand?: boolean; householdsWithTool?: string[];
id: string; id: string;
slug: string; slug: string;
} }
export interface RecipeToolResponse { export interface RecipeToolResponse {
name: string; name: string;
onHand?: boolean; householdsWithTool?: string[];
id: string; id: string;
slug: string; slug: string;
recipes?: RecipeSummary[]; recipes?: RecipeSummary[];
} }
export interface RecipeToolSave { export interface RecipeToolSave {
name: string; name: string;
onHand?: boolean; householdsWithTool?: string[];
groupId: string; groupId: string;
} }
export interface RecipeZipTokenResponse { export interface RecipeZipTokenResponse {
@ -478,9 +476,9 @@ export interface SaveIngredientFood {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
labelId?: string | null; labelId?: string | null;
aliases?: CreateIngredientFoodAlias[]; aliases?: CreateIngredientFoodAlias[];
householdsWithIngredientFood?: string[];
groupId: string; groupId: string;
} }
export interface SaveIngredientUnit { export interface SaveIngredientUnit {
@ -491,7 +489,6 @@ export interface SaveIngredientUnit {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
fraction?: boolean; fraction?: boolean;
abbreviation?: string; abbreviation?: string;
pluralAbbreviation?: string | null; pluralAbbreviation?: string | null;
@ -536,7 +533,6 @@ export interface UnitFoodBase {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
} }
export interface UpdateImageResponse { export interface UpdateImageResponse {
image: string; image: string;

View file

@ -11,6 +11,7 @@ import {
CreateInviteToken, CreateInviteToken,
ReadInviteToken, ReadInviteToken,
HouseholdSummary, HouseholdSummary,
HouseholdRecipeSummary,
} from "~/lib/api/types/household"; } from "~/lib/api/types/household";
const prefix = "/api"; const prefix = "/api";
@ -26,6 +27,7 @@ const routes = {
invitation: `${prefix}/households/invitations`, invitation: `${prefix}/households/invitations`,
householdsId: (id: string | number) => `${prefix}/groups/households/${id}`, householdsId: (id: string | number) => `${prefix}/groups/households/${id}`,
householdsSelfRecipesSlug: (recipeSlug: string) => `${prefix}/households/self/recipes/${recipeSlug}`,
}; };
export class HouseholdAPI extends BaseCRUDAPIReadOnly<HouseholdSummary> { export class HouseholdAPI extends BaseCRUDAPIReadOnly<HouseholdSummary> {
@ -37,6 +39,10 @@ export class HouseholdAPI extends BaseCRUDAPIReadOnly<HouseholdSummary> {
return await this.requests.get<HouseholdInDB>(routes.householdsSelf); return await this.requests.get<HouseholdInDB>(routes.householdsSelf);
} }
async getCurrentUserHouseholdRecipe(recipeSlug: string) {
return await this.requests.get<HouseholdRecipeSummary>(routes.householdsSelfRecipesSlug(recipeSlug));
}
async getPreferences() { async getPreferences() {
return await this.requests.get<ReadHouseholdPreferences>(routes.preferences); return await this.requests.get<ReadHouseholdPreferences>(routes.preferences);
} }

View file

@ -370,6 +370,7 @@ export default {
dir: "auto", dir: "auto",
name: "Mealie", name: "Mealie",
short_name: "Mealie", short_name: "Mealie",
crossorigin: "use-credentials",
id: "mealie", id: "mealie",
description: "Mealie is a recipe management and meal planning app", description: "Mealie is a recipe management and meal planning app",
theme_color: process.env.THEME_LIGHT_PRIMARY || "#E58325", theme_color: process.env.THEME_LIGHT_PRIMARY || "#E58325",

View file

@ -1,6 +1,6 @@
{ {
"name": "mealie", "name": "mealie",
"version": "2.4.2", "version": "2.5.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "nuxt", "dev": "nuxt",

View file

@ -5,8 +5,8 @@
:icon="$globals.icons.potSteam" :icon="$globals.icons.potSteam"
:items="tools" :items="tools"
item-type="tools" item-type="tools"
@delete="actions.deleteOne" @delete="deleteOne"
@update="actions.updateOne" @update="updateOne"
> >
<template #title> {{ $t("tool.tools") }} </template> <template #title> {{ $t("tool.tools") }} </template>
</RecipeOrganizerPage> </RecipeOrganizerPage>
@ -14,9 +14,14 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api"; import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import RecipeOrganizerPage from "~/components/Domain/Recipe/RecipeOrganizerPage.vue"; import RecipeOrganizerPage from "~/components/Domain/Recipe/RecipeOrganizerPage.vue";
import { useToolStore } from "~/composables/store"; import { useToolStore } from "~/composables/store";
import { RecipeTool } from "~/lib/api/types/recipe";
interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
export default defineComponent({ export default defineComponent({
components: { components: {
@ -24,13 +29,42 @@ export default defineComponent({
}, },
middleware: ["auth", "group-only"], middleware: ["auth", "group-only"],
setup() { setup() {
const { $auth } = useContext();
const toolStore = useToolStore(); const toolStore = useToolStore();
const dialog = ref(false); const dialog = ref(false);
const userHousehold = computed(() => $auth.user?.householdSlug || "");
const tools = computed(() => toolStore.store.value.map((tool) => (
{
...tool,
onHand: tool.householdsWithTool?.includes(userHousehold.value) || false
} as RecipeToolWithOnHand
)));
async function deleteOne(id: string | number) {
await toolStore.actions.deleteOne(id);
}
async function updateOne(tool: RecipeToolWithOnHand) {
if (userHousehold.value) {
if (tool.onHand && !tool.householdsWithTool?.includes(userHousehold.value)) {
if (!tool.householdsWithTool) {
tool.householdsWithTool = [userHousehold.value];
} else {
tool.householdsWithTool.push(userHousehold.value);
}
} else if (!tool.onHand && tool.householdsWithTool?.includes(userHousehold.value)) {
tool.householdsWithTool = tool.householdsWithTool.filter((household) => household !== userHousehold.value);
}
}
await toolStore.actions.updateOne(tool);
}
return { return {
dialog, dialog,
tools: toolStore.store, tools,
actions: toolStore.actions, deleteOne,
updateOne,
}; };
}, },
head() { head() {

View file

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- Merge Dialog --> <!-- Merge Dialog -->
<BaseDialog v-model="mergeDialog" :icon="$globals.icons.foods" :title="$t('data-pages.foods.combine-food')" @confirm="mergeFoods"> <BaseDialog v-model="mergeDialog" :icon="$globals.icons.foods" :title="$tc('data-pages.foods.combine-food')" @confirm="mergeFoods">
<v-card-text> <v-card-text>
<div> <div>
{{ $t("data-pages.foods.merge-dialog-text") }} {{ $t("data-pages.foods.merge-dialog-text") }}
@ -58,7 +58,7 @@
<BaseDialog <BaseDialog
v-model="createDialog" v-model="createDialog"
:icon="$globals.icons.foods" :icon="$globals.icons.foods"
:title="$t('data-pages.foods.create-food')" :title="$tc('data-pages.foods.create-food')"
:submit-icon="$globals.icons.save" :submit-icon="$globals.icons.save"
:submit-text="$tc('general.save')" :submit-text="$tc('general.save')"
@submit="createFood" @submit="createFood"
@ -111,7 +111,7 @@
<BaseDialog <BaseDialog
v-model="editDialog" v-model="editDialog"
:icon="$globals.icons.foods" :icon="$globals.icons.foods"
:title="$t('data-pages.foods.edit-food')" :title="$tc('data-pages.foods.edit-food')"
:submit-icon="$globals.icons.save" :submit-icon="$globals.icons.save"
:submit-text="$tc('general.save')" :submit-text="$tc('general.save')"
@submit="editSaveFood" @submit="editSaveFood"
@ -196,7 +196,7 @@
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<!-- Bulk Asign Labels Dialog --> <!-- Bulk Assign Labels Dialog -->
<BaseDialog <BaseDialog
v-model="bulkAssignLabelDialog" v-model="bulkAssignLabelDialog"
:title="$tc('data-pages.labels.assign-label')" :title="$tc('data-pages.labels.assign-label')"
@ -292,11 +292,20 @@ import { useFoodStore, useLabelStore } from "~/composables/store";
import { VForm } from "~/types/vuetify"; import { VForm } from "~/types/vuetify";
import { MultiPurposeLabelOut } from "~/lib/api/types/labels"; import { MultiPurposeLabelOut } from "~/lib/api/types/labels";
interface CreateIngredientFoodWithOnHand extends CreateIngredientFood {
onHand: boolean;
householdsWithIngredientFood: string[];
}
interface IngredientFoodWithOnHand extends IngredientFood {
onHand: boolean;
}
export default defineComponent({ export default defineComponent({
components: { MultiPurposeLabel, RecipeDataAliasManagerDialog }, components: { MultiPurposeLabel, RecipeDataAliasManagerDialog },
setup() { setup() {
const userApi = useUserApi(); const userApi = useUserApi();
const { i18n } = useContext(); const { $auth, i18n } = useContext();
const tableConfig = { const tableConfig = {
hideColumns: true, hideColumns: true,
canExport: true, canExport: true,
@ -352,15 +361,22 @@ export default defineComponent({
} }
} }
const userHousehold = computed(() => $auth.user?.householdSlug || "");
const foodStore = useFoodStore(); const foodStore = useFoodStore();
const foods = computed(() => foodStore.store.value.map((food) => {
const onHand = food.householdsWithIngredientFood?.includes(userHousehold.value) || false;
return { ...food, onHand } as IngredientFoodWithOnHand;
}));
// =============================================================== // ===============================================================
// Food Creator // Food Creator
const domNewFoodForm = ref<VForm>(); const domNewFoodForm = ref<VForm>();
const createDialog = ref(false); const createDialog = ref(false);
const createTarget = ref<CreateIngredientFood>({ const createTarget = ref<CreateIngredientFoodWithOnHand>({
name: "", name: "",
onHand: false,
householdsWithIngredientFood: [],
}); });
function createEventHandler() { function createEventHandler() {
@ -372,6 +388,10 @@ export default defineComponent({
return; return;
} }
if (createTarget.value.onHand) {
createTarget.value.householdsWithIngredientFood = [userHousehold.value];
}
// @ts-expect-error the createOne function erroneously expects an id because it uses the IngredientFood type // @ts-expect-error the createOne function erroneously expects an id because it uses the IngredientFood type
await foodStore.actions.createOne(createTarget.value); await foodStore.actions.createOne(createTarget.value);
createDialog.value = false; createDialog.value = false;
@ -379,6 +399,8 @@ export default defineComponent({
domNewFoodForm.value?.reset(); domNewFoodForm.value?.reset();
createTarget.value = { createTarget.value = {
name: "", name: "",
onHand: false,
householdsWithIngredientFood: [],
}; };
} }
@ -386,10 +408,11 @@ export default defineComponent({
// Food Editor // Food Editor
const editDialog = ref(false); const editDialog = ref(false);
const editTarget = ref<IngredientFood | null>(null); const editTarget = ref<IngredientFoodWithOnHand | null>(null);
function editEventHandler(item: IngredientFood) { function editEventHandler(item: IngredientFoodWithOnHand) {
editTarget.value = item; editTarget.value = item;
editTarget.value.onHand = item.householdsWithIngredientFood?.includes(userHousehold.value) || false;
editDialog.value = true; editDialog.value = true;
} }
@ -397,6 +420,17 @@ export default defineComponent({
if (!editTarget.value) { if (!editTarget.value) {
return; return;
} }
if (editTarget.value.onHand && !editTarget.value.householdsWithIngredientFood?.includes(userHousehold.value)) {
if (!editTarget.value.householdsWithIngredientFood) {
editTarget.value.householdsWithIngredientFood = [userHousehold.value];
} else {
editTarget.value.householdsWithIngredientFood.push(userHousehold.value);
}
} else if (!editTarget.value.onHand && editTarget.value.householdsWithIngredientFood?.includes(userHousehold.value)) {
editTarget.value.householdsWithIngredientFood = editTarget.value.householdsWithIngredientFood.filter(
(household) => household !== userHousehold.value
);
}
await foodStore.actions.updateOne(editTarget.value); await foodStore.actions.updateOne(editTarget.value);
editDialog.value = false; editDialog.value = false;
@ -406,8 +440,8 @@ export default defineComponent({
// Food Delete // Food Delete
const deleteDialog = ref(false); const deleteDialog = ref(false);
const deleteTarget = ref<IngredientFood | null>(null); const deleteTarget = ref<IngredientFoodWithOnHand | null>(null);
function deleteEventHandler(item: IngredientFood) { function deleteEventHandler(item: IngredientFoodWithOnHand) {
deleteTarget.value = item; deleteTarget.value = item;
deleteDialog.value = true; deleteDialog.value = true;
} }
@ -421,9 +455,9 @@ export default defineComponent({
} }
const bulkDeleteDialog = ref(false); const bulkDeleteDialog = ref(false);
const bulkDeleteTarget = ref<IngredientFood[]>([]); const bulkDeleteTarget = ref<IngredientFoodWithOnHand[]>([]);
function bulkDeleteEventHandler(selection: IngredientFood[]) { function bulkDeleteEventHandler(selection: IngredientFoodWithOnHand[]) {
bulkDeleteTarget.value = selection; bulkDeleteTarget.value = selection;
bulkDeleteDialog.value = true; bulkDeleteDialog.value = true;
} }
@ -455,8 +489,8 @@ export default defineComponent({
// Merge Foods // Merge Foods
const mergeDialog = ref(false); const mergeDialog = ref(false);
const fromFood = ref<IngredientFood | null>(null); const fromFood = ref<IngredientFoodWithOnHand | null>(null);
const toFood = ref<IngredientFood | null>(null); const toFood = ref<IngredientFoodWithOnHand | null>(null);
const canMerge = computed(() => { const canMerge = computed(() => {
return fromFood.value && toFood.value && fromFood.value.id !== toFood.value.id; return fromFood.value && toFood.value && fromFood.value.id !== toFood.value.id;
@ -506,10 +540,10 @@ export default defineComponent({
// ============================================================ // ============================================================
// Bulk Assign Labels // Bulk Assign Labels
const bulkAssignLabelDialog = ref(false); const bulkAssignLabelDialog = ref(false);
const bulkAssignTarget = ref<IngredientFood[]>([]); const bulkAssignTarget = ref<IngredientFoodWithOnHand[]>([]);
const bulkAssignLabelId = ref<string | undefined>(); const bulkAssignLabelId = ref<string | undefined>();
function bulkAssignEventHandler(selection: IngredientFood[]) { function bulkAssignEventHandler(selection: IngredientFoodWithOnHand[]) {
bulkAssignTarget.value = selection; bulkAssignTarget.value = selection;
bulkAssignLabelDialog.value = true; bulkAssignLabelDialog.value = true;
} }
@ -530,7 +564,7 @@ export default defineComponent({
return { return {
tableConfig, tableConfig,
tableHeaders, tableHeaders,
foods: foodStore.store, foods,
allLabels, allLabels,
validators, validators,
formatDate, formatDate,

View file

@ -109,11 +109,6 @@
<template #button-row> <template #button-row>
<BaseButton create @click="state.createDialog = true">{{ $t("general.create") }}</BaseButton> <BaseButton create @click="state.createDialog = true">{{ $t("general.create") }}</BaseButton>
</template> </template>
<template #item.onHand="{ item }">
<v-icon :color="item.onHand ? 'success' : undefined">
{{ item.onHand ? $globals.icons.check : $globals.icons.close }}
</v-icon>
</template>
</CrudTable> </CrudTable>
</div> </div>
</template> </template>

View file

@ -101,14 +101,18 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api"; import { computed, defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import { useToolStore, useToolData } from "~/composables/store"; import { useToolStore, useToolData } from "~/composables/store";
import { RecipeTool } from "~/lib/api/types/admin"; import { RecipeTool } from "~/lib/api/types/recipe";
interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
export default defineComponent({ export default defineComponent({
setup() { setup() {
const { i18n } = useContext(); const { $auth, i18n } = useContext();
const tableConfig = { const tableConfig = {
hideColumns: true, hideColumns: true,
canExport: true, canExport: true,
@ -138,27 +142,38 @@ export default defineComponent({
bulkDeleteDialog: false, bulkDeleteDialog: false,
}); });
const userHousehold = computed(() => $auth.user?.householdSlug || "");
const toolData = useToolData(); const toolData = useToolData();
const toolStore = useToolStore(); const toolStore = useToolStore();
const tools = computed(() => toolStore.store.value.map((tools) => {
const onHand = tools.householdsWithTool?.includes(userHousehold.value) || false;
return { ...tools, onHand } as RecipeToolWithOnHand;
}));
// ============================================================ // ============================================================
// Create Tag // Create Tool
async function createTool() { async function createTool() {
if (toolData.data.onHand) {
toolData.data.householdsWithTool = [userHousehold.value];
} else {
toolData.data.householdsWithTool = [];
}
// @ts-ignore - only property really required is the name and onHand (RecipeOrganizerPage) // @ts-ignore - only property really required is the name and onHand (RecipeOrganizerPage)
await toolStore.actions.createOne({ name: toolData.data.name, onHand: toolData.data.onHand }); await toolStore.actions.createOne({ name: toolData.data.name, householdsWithTool: toolData.data.householdsWithTool });
toolData.reset(); toolData.reset();
state.createDialog = false; state.createDialog = false;
} }
// ============================================================ // ============================================================
// Edit Tag // Edit Tool
const editTarget = ref<RecipeTool | null>(null); const editTarget = ref<RecipeToolWithOnHand | null>(null);
function editEventHandler(item: RecipeTool) { function editEventHandler(item: RecipeToolWithOnHand) {
state.editDialog = true; state.editDialog = true;
editTarget.value = item; editTarget.value = item;
} }
@ -167,17 +182,29 @@ export default defineComponent({
if (!editTarget.value) { if (!editTarget.value) {
return; return;
} }
if (editTarget.value.onHand && !editTarget.value.householdsWithTool?.includes(userHousehold.value)) {
if (!editTarget.value.householdsWithTool) {
editTarget.value.householdsWithTool = [userHousehold.value];
} else {
editTarget.value.householdsWithTool.push(userHousehold.value);
}
} else if (!editTarget.value.onHand && editTarget.value.householdsWithTool?.includes(userHousehold.value)) {
editTarget.value.householdsWithTool = editTarget.value.householdsWithTool.filter(
(household) => household !== userHousehold.value
);
}
await toolStore.actions.updateOne(editTarget.value); await toolStore.actions.updateOne(editTarget.value);
state.editDialog = false; state.editDialog = false;
} }
// ============================================================ // ============================================================
// Delete Tag // Delete Tool
const deleteTarget = ref<RecipeTool | null>(null); const deleteTarget = ref<RecipeToolWithOnHand | null>(null);
function deleteEventHandler(item: RecipeTool) { function deleteEventHandler(item: RecipeToolWithOnHand) {
state.deleteDialog = true; state.deleteDialog = true;
deleteTarget.value = item; deleteTarget.value = item;
} }
@ -191,10 +218,10 @@ export default defineComponent({
} }
// ============================================================ // ============================================================
// Bulk Delete Tag // Bulk Delete Tool
const bulkDeleteTarget = ref<RecipeTool[]>([]); const bulkDeleteTarget = ref<RecipeToolWithOnHand[]>([]);
function bulkDeleteEventHandler(selection: RecipeTool[]) { function bulkDeleteEventHandler(selection: RecipeToolWithOnHand[]) {
bulkDeleteTarget.value = selection; bulkDeleteTarget.value = selection;
state.bulkDeleteDialog = true; state.bulkDeleteDialog = true;
} }
@ -210,7 +237,7 @@ export default defineComponent({
state, state,
tableConfig, tableConfig,
tableHeaders, tableHeaders,
tools: toolStore.store, tools,
validators, validators,
// create // create

View file

@ -0,0 +1,263 @@
"""add household to recipe last made, household to foods, and household to tools
Revision ID: b9e516e2d3b3
Revises: b1020f328e98
Create Date: 2024-11-20 17:30:41.152332
"""
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.orm import DeclarativeBase
import mealie.db.migration_types
from alembic import op
from mealie.core.root_logger import get_logger
from mealie.db.models._model_utils.datetime import NaiveDateTime
from mealie.db.models._model_utils.guid import GUID
# revision identifiers, used by Alembic.
revision = "b9e516e2d3b3"
down_revision: str | None = "b1020f328e98"
branch_labels: str | tuple[str, ...] | None = None
depends_on: str | tuple[str, ...] | None = None
logger = get_logger()
class SqlAlchemyBase(DeclarativeBase):
pass
# Intermediate table definitions
class Group(SqlAlchemyBase):
__tablename__ = "groups"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
class Household(SqlAlchemyBase):
__tablename__ = "households"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
class RecipeModel(SqlAlchemyBase):
__tablename__ = "recipes"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
last_made: orm.Mapped[datetime | None] = orm.mapped_column(NaiveDateTime)
class HouseholdToRecipe(SqlAlchemyBase):
__tablename__ = "households_to_recipes"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
household_id = sa.Column(GUID, sa.ForeignKey("households.id"), index=True, primary_key=True)
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"), index=True, primary_key=True)
last_made: orm.Mapped[datetime | None] = orm.mapped_column(NaiveDateTime)
class IngredientFoodModel(SqlAlchemyBase):
__tablename__ = "ingredient_foods"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
on_hand: orm.Mapped[bool] = orm.mapped_column(sa.Boolean, default=False)
class Tool(SqlAlchemyBase):
__tablename__ = "tools"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
on_hand: orm.Mapped[bool | None] = orm.mapped_column(sa.Boolean, default=False)
def migrate_recipe_last_made_to_household(session: orm.Session):
for group in session.query(Group).all():
households = session.query(Household).filter(Household.group_id == group.id).all()
recipes = (
session.query(RecipeModel)
.filter(
RecipeModel.group_id == group.id,
RecipeModel.last_made != None, # noqa E711
)
.all()
)
for recipe in recipes:
for household in households:
session.add(
HouseholdToRecipe(
household_id=household.id,
recipe_id=recipe.id,
last_made=recipe.last_made,
)
)
def migrate_foods_on_hand_to_household(session: orm.Session):
dialect = op.get_bind().dialect
for group in session.query(Group).all():
households = session.query(Household).filter(Household.group_id == group.id).all()
foods = (
session.query(IngredientFoodModel)
.filter(
IngredientFoodModel.group_id == group.id,
IngredientFoodModel.on_hand == True, # noqa E712
)
.all()
)
for food in foods:
for household in households:
session.execute(
sa.text(
"INSERT INTO households_to_ingredient_foods (household_id, food_id)"
"VALUES (:household_id, :food_id)"
),
{
"household_id": GUID.convert_value_to_guid(household.id, dialect),
"food_id": GUID.convert_value_to_guid(food.id, dialect),
},
)
def migrate_tools_on_hand_to_household(session: orm.Session):
dialect = op.get_bind().dialect
for group in session.query(Group).all():
households = session.query(Household).filter(Household.group_id == group.id).all()
tools = (
session.query(Tool)
.filter(
Tool.group_id == group.id,
Tool.on_hand == True, # noqa E712
)
.all()
)
for tool in tools:
for household in households:
session.execute(
sa.text("INSERT INTO households_to_tools (household_id, tool_id) VALUES (:household_id, :tool_id)"),
{
"household_id": GUID.convert_value_to_guid(household.id, dialect),
"tool_id": GUID.convert_value_to_guid(tool.id, dialect),
},
)
def migrate_to_new_models():
bind = op.get_bind()
session = orm.Session(bind=bind)
for migration_func in [
migrate_recipe_last_made_to_household,
migrate_foods_on_hand_to_household,
migrate_tools_on_hand_to_household,
]:
try:
logger.info(f"Running new model migration ({migration_func.__name__})")
migration_func(session)
session.commit()
except Exception:
session.rollback()
logger.error(f"Error during new model migration ({migration_func.__name__})")
raise
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"households_to_recipes",
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("recipe_id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("last_made", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.Column("created_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.Column("update_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.ForeignKeyConstraint(
["household_id"],
["households.id"],
),
sa.ForeignKeyConstraint(
["recipe_id"],
["recipes.id"],
),
sa.PrimaryKeyConstraint("id", "household_id", "recipe_id"),
sa.UniqueConstraint("household_id", "recipe_id", name="household_id_recipe_id_key"),
)
with op.batch_alter_table("households_to_recipes", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_households_to_recipes_created_at"), ["created_at"], unique=False)
batch_op.create_index(batch_op.f("ix_households_to_recipes_household_id"), ["household_id"], unique=False)
batch_op.create_index(batch_op.f("ix_households_to_recipes_recipe_id"), ["recipe_id"], unique=False)
op.create_table(
"households_to_tools",
sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True),
sa.Column("tool_id", mealie.db.migration_types.GUID(), nullable=True),
sa.ForeignKeyConstraint(
["household_id"],
["households.id"],
),
sa.ForeignKeyConstraint(
["tool_id"],
["tools.id"],
),
sa.UniqueConstraint("household_id", "tool_id", name="household_id_tool_id_key"),
)
with op.batch_alter_table("households_to_tools", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_households_to_tools_household_id"), ["household_id"], unique=False)
batch_op.create_index(batch_op.f("ix_households_to_tools_tool_id"), ["tool_id"], unique=False)
op.create_table(
"households_to_ingredient_foods",
sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True),
sa.Column("food_id", mealie.db.migration_types.GUID(), nullable=True),
sa.ForeignKeyConstraint(
["food_id"],
["ingredient_foods.id"],
),
sa.ForeignKeyConstraint(
["household_id"],
["households.id"],
),
sa.UniqueConstraint("household_id", "food_id", name="household_id_food_id_key"),
)
with op.batch_alter_table("households_to_ingredient_foods", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_households_to_ingredient_foods_food_id"), ["food_id"], unique=False)
batch_op.create_index(
batch_op.f("ix_households_to_ingredient_foods_household_id"), ["household_id"], unique=False
)
# ### end Alembic commands ###
migrate_to_new_models()
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("households_to_ingredient_foods", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_households_to_ingredient_foods_household_id"))
batch_op.drop_index(batch_op.f("ix_households_to_ingredient_foods_food_id"))
op.drop_table("households_to_ingredient_foods")
with op.batch_alter_table("households_to_tools", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_households_to_tools_tool_id"))
batch_op.drop_index(batch_op.f("ix_households_to_tools_household_id"))
op.drop_table("households_to_tools")
with op.batch_alter_table("households_to_recipes", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_households_to_recipes_recipe_id"))
batch_op.drop_index(batch_op.f("ix_households_to_recipes_household_id"))
batch_op.drop_index(batch_op.f("ix_households_to_recipes_created_at"))
op.drop_table("households_to_recipes")
# ### end Alembic commands ###

View file

@ -26,8 +26,7 @@ def fix_group_with_no_name(session: Session):
return return
logger.info( logger.info(
f'{len(groups)} {"group" if len(groups) == 1 else "groups"} found with a missing name; ' f"{len(groups)} {'group' if len(groups) == 1 else 'groups'} found with a missing name; applying default name"
f"applying default name"
) )
offset = 0 offset = 0

View file

@ -51,7 +51,7 @@ def fix_dangling_refs(session: Session):
if result.rowcount: if result.rowcount:
logger.info( logger.info(
f'Reassigned {result.rowcount} {"row" if result.rowcount == 1 else "rows"} ' f"Reassigned {result.rowcount} {'row' if result.rowcount == 1 else 'rows'} "
f'in "{table_name}" table to default user ({default_user.id})' f'in "{table_name}" table to default user ({default_user.id})'
) )
@ -63,7 +63,7 @@ def fix_dangling_refs(session: Session):
if result.rowcount: if result.rowcount:
logger.info( logger.info(
f'Deleted {result.rowcount} {"row" if result.rowcount == 1 else "rows"} ' f"Deleted {result.rowcount} {'row' if result.rowcount == 1 else 'rows'} "
f'in "{table_name}" table with invalid user ids' f'in "{table_name}" table with invalid user ids'
) )

View file

@ -1,6 +1,7 @@
from .cookbook import CookBook from .cookbook import CookBook
from .events import GroupEventNotifierModel, GroupEventNotifierOptionsModel from .events import GroupEventNotifierModel, GroupEventNotifierOptionsModel
from .household import Household from .household import Household
from .household_to_recipe import HouseholdToRecipe
from .invite_tokens import GroupInviteToken from .invite_tokens import GroupInviteToken
from .mealplan import GroupMealPlan, GroupMealPlanRules from .mealplan import GroupMealPlan, GroupMealPlanRules
from .preferences import HouseholdPreferencesModel from .preferences import HouseholdPreferencesModel
@ -24,6 +25,7 @@ __all__ = [
"GroupMealPlanRules", "GroupMealPlanRules",
"Household", "Household",
"HouseholdPreferencesModel", "HouseholdPreferencesModel",
"HouseholdToRecipe",
"GroupRecipeAction", "GroupRecipeAction",
"ShoppingList", "ShoppingList",
"ShoppingListExtras", "ShoppingListExtras",

View file

@ -8,9 +8,13 @@ from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
from ..recipe.ingredient import households_to_ingredient_foods
from ..recipe.tool import households_to_tools
from .household_to_recipe import HouseholdToRecipe
if TYPE_CHECKING: if TYPE_CHECKING:
from ..group import Group from ..group import Group
from ..recipe import IngredientFoodModel, RecipeModel, Tool
from ..users import User from ..users import User
from . import ( from . import (
CookBook, CookBook,
@ -62,6 +66,18 @@ class Household(SqlAlchemyBase, BaseMixins):
"GroupEventNotifierModel", **COMMON_ARGS "GroupEventNotifierModel", **COMMON_ARGS
) )
made_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=HouseholdToRecipe.__tablename__, back_populates="made_by"
)
ingredient_foods_on_hand: Mapped[list["IngredientFoodModel"]] = orm.relationship(
"IngredientFoodModel",
secondary=households_to_ingredient_foods,
back_populates="households_with_ingredient_food",
)
tools_on_hand: Mapped[list["Tool"]] = orm.relationship(
"Tool", secondary=households_to_tools, back_populates="households_with_tool"
)
model_config = ConfigDict( model_config = ConfigDict(
exclude={ exclude={
"users", "users",
@ -72,6 +88,7 @@ class Household(SqlAlchemyBase, BaseMixins):
"invite_tokens", "invite_tokens",
"group_event_notifiers", "group_event_notifiers",
"group", "group",
"made_recipes",
} }
) )

View file

@ -0,0 +1,60 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Column, ForeignKey, UniqueConstraint, event
from sqlalchemy.engine.base import Connection
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm.session import Session
from mealie.db.models._model_utils.datetime import NaiveDateTime
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
if TYPE_CHECKING:
from ..recipe import RecipeModel
from .household import Household
class HouseholdToRecipe(SqlAlchemyBase, BaseMixins):
__tablename__ = "households_to_recipes"
__table_args__ = (UniqueConstraint("household_id", "recipe_id", name="household_id_recipe_id_key"),)
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
household: Mapped["Household"] = relationship("Household", viewonly=True)
household_id = Column(GUID, ForeignKey("households.id"), index=True, primary_key=True)
recipe: Mapped["RecipeModel"] = relationship("RecipeModel", viewonly=True)
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True, primary_key=True)
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime)
@auto_init()
def __init__(self, **_) -> None:
pass
def update_recipe_last_made(session: Session, target: HouseholdToRecipe):
if not target.last_made:
return
from mealie.db.models.recipe.recipe import RecipeModel
recipe = session.query(RecipeModel).filter(RecipeModel.id == target.recipe_id).first()
if not recipe:
return
recipe.last_made = recipe.last_made or target.last_made
recipe.last_made = max(recipe.last_made, target.last_made)
@event.listens_for(HouseholdToRecipe, "after_insert")
@event.listens_for(HouseholdToRecipe, "after_update")
@event.listens_for(HouseholdToRecipe, "after_delete")
def update_recipe_rating_on_insert_or_delete(_, connection: Connection, target: HouseholdToRecipe):
session = Session(bind=connection)
update_recipe_last_made(session, target)
session.commit()

View file

@ -1,6 +1,7 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import sqlalchemy as sa import sqlalchemy as sa
from pydantic import ConfigDict
from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, event, orm from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, event, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -14,6 +15,16 @@ from .._model_utils.guid import GUID
if TYPE_CHECKING: if TYPE_CHECKING:
from ..group import Group from ..group import Group
from ..household import Household
households_to_ingredient_foods = sa.Table(
"households_to_ingredient_foods",
SqlAlchemyBase.metadata,
sa.Column("household_id", GUID, sa.ForeignKey("households.id"), index=True),
sa.Column("food_id", GUID, sa.ForeignKey("ingredient_foods.id"), index=True),
sa.UniqueConstraint("household_id", "food_id", name="household_id_food_id_key"),
)
class IngredientUnitModel(SqlAlchemyBase, BaseMixins): class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
@ -142,11 +153,13 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
# ID Relationships # ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id])
households_with_ingredient_food: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=households_to_ingredient_foods, back_populates="ingredient_foods_on_hand"
)
name: Mapped[str | None] = mapped_column(String) name: Mapped[str | None] = mapped_column(String)
plural_name: Mapped[str | None] = mapped_column(String) plural_name: Mapped[str | None] = mapped_column(String)
description: Mapped[str | None] = mapped_column(String) description: Mapped[str | None] = mapped_column(String)
on_hand: Mapped[bool] = mapped_column(Boolean)
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship( ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel", back_populates="food" "RecipeIngredientModel", back_populates="food"
@ -165,20 +178,42 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
model_config = ConfigDict(
exclude={
"households_with_ingredient_food",
}
)
# Deprecated
on_hand: Mapped[bool] = mapped_column(Boolean, default=False)
@api_extras @api_extras
@auto_init() @auto_init()
def __init__( def __init__(
self, self,
session: Session, session: Session,
group_id: GUID,
name: str | None = None, name: str | None = None,
plural_name: str | None = None, plural_name: str | None = None,
households_with_ingredient_food: list[str] | None = None,
**_, **_,
) -> None: ) -> None:
from ..household import Household
if name is not None: if name is not None:
self.name_normalized = self.normalize(name) self.name_normalized = self.normalize(name)
if plural_name is not None: if plural_name is not None:
self.plural_name_normalized = self.normalize(plural_name) self.plural_name_normalized = self.normalize(plural_name)
if not households_with_ingredient_food:
self.households_with_ingredient_food = []
else:
self.households_with_ingredient_food = (
session.query(Household)
.filter(Household.group_id == group_id, Household.slug.in_(households_with_ingredient_food))
.all()
)
tableargs = [ tableargs = [
sa.UniqueConstraint("name", "group_id", name="ingredient_foods_name_group_id_key"), sa.UniqueConstraint("name", "group_id", name="ingredient_foods_name_group_id_key"),
sa.Index( sa.Index(

View file

@ -16,6 +16,7 @@ from mealie.db.models._model_utils.datetime import NaiveDateTime, get_utc_today
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from ..household.household_to_recipe import HouseholdToRecipe
from ..users.user_to_recipe import UserToRecipe from ..users.user_to_recipe import UserToRecipe
from .api_extras import ApiExtras, api_extras from .api_extras import ApiExtras, api_extras
from .assets import RecipeAsset from .assets import RecipeAsset
@ -136,7 +137,11 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
# Time Stamp Properties # Time Stamp Properties
date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today) date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today)
date_updated: Mapped[datetime | None] = mapped_column(NaiveDateTime) date_updated: Mapped[datetime | None] = mapped_column(NaiveDateTime)
last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime) last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime)
made_by: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=HouseholdToRecipe.__tablename__, back_populates="made_recipes"
)
# Shopping List Refs # Shopping List Refs
shopping_list_refs: Mapped[list["ShoppingListRecipeReference"]] = orm.relationship( shopping_list_refs: Mapped[list["ShoppingListRecipeReference"]] = orm.relationship(

View file

@ -1,5 +1,6 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pydantic import ConfigDict
from slugify import slugify from slugify import slugify
from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@ -10,8 +11,17 @@ from mealie.db.models._model_utils.guid import GUID
if TYPE_CHECKING: if TYPE_CHECKING:
from ..group import Group from ..group import Group
from ..household import Household
from . import RecipeModel from . import RecipeModel
households_to_tools = Table(
"households_to_tools",
SqlAlchemyBase.metadata,
Column("household_id", GUID, ForeignKey("households.id"), index=True),
Column("tool_id", GUID, ForeignKey("tools.id"), index=True),
UniqueConstraint("household_id", "tool_id", name="household_id_tool_id_key"),
)
recipes_to_tools = Table( recipes_to_tools = Table(
"recipes_to_tools", "recipes_to_tools",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
@ -40,11 +50,36 @@ class Tool(SqlAlchemyBase, BaseMixins):
name: Mapped[str] = mapped_column(String, index=True, nullable=False) name: Mapped[str] = mapped_column(String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(String, index=True, nullable=False) slug: Mapped[str] = mapped_column(String, index=True, nullable=False)
on_hand: Mapped[bool | None] = mapped_column(Boolean, default=False)
households_with_tool: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=households_to_tools, back_populates="tools_on_hand"
)
recipes: Mapped[list["RecipeModel"]] = orm.relationship( recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=recipes_to_tools, back_populates="tools" "RecipeModel", secondary=recipes_to_tools, back_populates="tools"
) )
model_config = ConfigDict(
exclude={
"households_with_tool",
}
)
# Deprecated
on_hand: Mapped[bool | None] = mapped_column(Boolean, default=False)
@auto_init() @auto_init()
def __init__(self, name, **_) -> None: def __init__(
self, session: orm.Session, group_id: GUID, name: str, households_with_tool: list[str] | None = None, **_
) -> None:
from ..household import Household
self.slug = slugify(name) self.slug = slugify(name)
if not households_with_tool:
self.households_with_tool = []
else:
self.households_with_tool = (
session.query(Household)
.filter(Household.group_id == group_id, Household.slug.in_(households_with_tool))
.all()
)

View file

@ -4,18 +4,18 @@
}, },
"recipe": { "recipe": {
"unique-name-error": "يجب أن تكون أسماء الوصفات فريدة", "unique-name-error": "يجب أن تكون أسماء الوصفات فريدة",
"recipe-created": "Recipe Created", "recipe-created": "تم إنشاء الوصفة",
"recipe-defaults": { "recipe-defaults": {
"ingredient-note": "1 Cup Flour", "ingredient-note": "كوب 1 طحين",
"step-text": "Recipe steps as well as other fields in the recipe page support markdown syntax.\n\n**Add a link**\n\n[My Link](https://demo.mealie.io)\n" "step-text": "تدعم خطوات الوصفة بالإضافة إلى الحقول الأخرى في صفحة الوصفة صياغة تخفيض السعر. **أضف رابطًا** [الرابط الخاص بي](https://demo.mealie.io)\n"
}, },
"servings-text": { "servings-text": {
"makes": "Makes", "makes": "تصنع",
"serves": "Serves", "serves": "يكفي ل",
"serving": "Serving", "serving": "حصة الطعام",
"servings": "Servings", "servings": "حصص الطعام",
"yield": "Yield", "yield": "العائد",
"yields": "Yields" "yields": "العائد"
} }
}, },
"mealplan": { "mealplan": {
@ -28,13 +28,13 @@
"ldap-update-password-unavailable": "غير قادر على تحديث كلمة المرور، المستخدم يتم التحكم به بواسطة LDAP" "ldap-update-password-unavailable": "غير قادر على تحديث كلمة المرور، المستخدم يتم التحكم به بواسطة LDAP"
}, },
"group": { "group": {
"report-deleted": "تم حذف التقرير" "report-deleted": "تم حذف التقرير."
}, },
"exceptions": { "exceptions": {
"permission_denied": "لا يوجد لديك صلاحيات كافية لتفيذ هذا الإجراء", "permission_denied": "لا يوجد لديك صلاحيات كافية لتنفيذ هذا الإجراء",
"no-entry-found": "لم يتم العثور على الصفحة المطلوبة", "no-entry-found": "لم يتم العثور على الصفحة المطلوبة",
"integrity-error": "خطأ في سلامة قاعدة البيانات", "integrity-error": "خطأ في سلامة قاعدة البيانات",
"username-conflict-error": "اسم المستخدم هذا مستخدم بالفعل", "username-conflict-error": "اسم المستخدم هذا مستخدم مسبقاً",
"email-conflict-error": "هذا البريد مستخدم مسبقاً" "email-conflict-error": "هذا البريد مستخدم مسبقاً"
}, },
"notifications": { "notifications": {
@ -46,35 +46,35 @@
"generic-deleted": "تم حذف {name}" "generic-deleted": "تم حذف {name}"
}, },
"datetime": { "datetime": {
"year": "year|years", "year": "سنة|سنوات",
"day": "day|days", "day": "يوم|أيام",
"hour": "hour|hours", "hour": "ساعة|ساعات",
"minute": "minute|minutes", "minute": "دقيقة|دقائق",
"second": "second|seconds", "second": "ثانية|ثواني",
"millisecond": "millisecond|milliseconds", "millisecond": "مللي ثانية|مللي ثانية",
"microsecond": "microsecond|microseconds" "microsecond": "مكرو ثانية|مكرو ثانية"
}, },
"emails": { "emails": {
"password": { "password": {
"subject": "Mealie Forgot Password", "subject": "نسيت كلمة المرور",
"header_text": "Forgot Password", "header_text": "نسيت كلمة المرور",
"message_top": "You have requested to reset your password.", "message_top": "لقد طلبت إعادة تعيين كلمة المرور الخاصة بك.",
"message_bottom": "Please click the button above to reset your password.", "message_bottom": "الرجاء النقر على الزر أعلاه لإعادة تعيين كلمة المرور الخاصة بك.",
"button_text": "Reset Password" "button_text": "إعادة ضبط كلمة المرور"
}, },
"invitation": { "invitation": {
"subject": "Invitation to join Mealie", "subject": "دعوة للانضمام إلى ميلي",
"header_text": "You're Invited!", "header_text": "أنت مدعو!",
"message_top": "You have been invited to join Mealie.", "message_top": "لقد تمت دعوتك للانضمام إلى ميلي.",
"message_bottom": "Please click the button above to accept the invitation.", "message_bottom": "الرجاء النقر على الزر أعلاه لقبول الدعوة.",
"button_text": "Accept Invitation" "button_text": "قَبُول الدعوة"
}, },
"test": { "test": {
"subject": "Mealie Test Email", "subject": "بريد إلكتروني لاختبار ميلي",
"header_text": "Test Email", "header_text": "بريد إلكتروني تجريبي",
"message_top": "This is a test email.", "message_top": "هذا بريد إلكتروني اختباري.",
"message_bottom": "Please click the button above to test the email.", "message_bottom": "الرجاء النقر على الزر أعلاه لاختبار البريد الإلكتروني.",
"button_text": "Open Mealie" "button_text": "فتح ميلي"
} }
} }
} }

View file

@ -4,7 +4,7 @@
}, },
"recipe": { "recipe": {
"unique-name-error": "Názvy receptů musí být jedinečné", "unique-name-error": "Názvy receptů musí být jedinečné",
"recipe-created": "Recipe Created", "recipe-created": "Recept byl vytvořen",
"recipe-defaults": { "recipe-defaults": {
"ingredient-note": "1 hrnek mouky", "ingredient-note": "1 hrnek mouky",
"step-text": "Kroky receptu stejně jako další pole v receptu podporují markdown syntaxi.\n\n**Přidat odkaz**\n\n[Můj odkaz](https://demo.mealie.io)\n" "step-text": "Kroky receptu stejně jako další pole v receptu podporují markdown syntaxi.\n\n**Přidat odkaz**\n\n[Můj odkaz](https://demo.mealie.io)\n"
@ -12,8 +12,8 @@
"servings-text": { "servings-text": {
"makes": "Makes", "makes": "Makes",
"serves": "Porce", "serves": "Porce",
"serving": "Serving", "serving": "Porce",
"servings": "Servings", "servings": "Porcí",
"yield": "Yield", "yield": "Yield",
"yields": "Yields" "yields": "Yields"
} }

View file

@ -42,7 +42,7 @@
"generic-updated": "{name} blev opdateret", "generic-updated": "{name} blev opdateret",
"generic-created-with-url": "{name} er oprettet, {url}", "generic-created-with-url": "{name} er oprettet, {url}",
"generic-updated-with-url": "{name} er blevet opdateret, {url}", "generic-updated-with-url": "{name} er blevet opdateret, {url}",
"generic-duplicated": "{name} er blevet dublikeret", "generic-duplicated": "{name} er blevet duplikeret",
"generic-deleted": "{name} er blevet slettet" "generic-deleted": "{name} er blevet slettet"
}, },
"datetime": { "datetime": {
@ -60,7 +60,7 @@
"header_text": "Glemt adgangskode", "header_text": "Glemt adgangskode",
"message_top": "Du har anmodet om at nulstille din adgangskode.", "message_top": "Du har anmodet om at nulstille din adgangskode.",
"message_bottom": "Klik på knappen ovenfor for at nulstille din adgangskode.", "message_bottom": "Klik på knappen ovenfor for at nulstille din adgangskode.",
"button_text": "Nulstil adgangskoden" "button_text": "Nulstil adgangskode"
}, },
"invitation": { "invitation": {
"subject": "Invitation til at deltage i Mealie", "subject": "Invitation til at deltage i Mealie",

View file

@ -4,18 +4,18 @@
}, },
"recipe": { "recipe": {
"unique-name-error": "El nombre de la receta debe ser único", "unique-name-error": "El nombre de la receta debe ser único",
"recipe-created": "Recipe Created", "recipe-created": "Receta creada",
"recipe-defaults": { "recipe-defaults": {
"ingredient-note": "1 Taza de harina", "ingredient-note": "1 Taza de harina",
"step-text": "Los pasos de receta, así como otros campos en la página de recetas, soportan sintaxis Markdown.\n\n**Agregar un enlace**\n\n[Mi Enlace](https://demo.mealie.io)\n" "step-text": "Los pasos de receta, así como otros campos en la página de recetas, soportan sintaxis Markdown.\n\n**Agregar un enlace**\n\n[Mi Enlace](https://demo.mealie.io)\n"
}, },
"servings-text": { "servings-text": {
"makes": "Makes", "makes": "Realizadas",
"serves": "Serves", "serves": "Personas",
"serving": "Serving", "serving": "Porción",
"servings": "Servings", "servings": "Porciones",
"yield": "Yield", "yield": "Ración",
"yields": "Yields" "yields": "Raciones"
} }
}, },
"mealplan": { "mealplan": {

View file

@ -1,80 +1,80 @@
{ {
"generic": { "generic": {
"server-error": "An unexpected error occurred" "server-error": "Ilmnes ootamatu tõrge "
}, },
"recipe": { "recipe": {
"unique-name-error": "Recipe names must be unique", "unique-name-error": "Retseptide nimed peavad olema unikaalsed",
"recipe-created": "Recipe Created", "recipe-created": "Retsept loodud",
"recipe-defaults": { "recipe-defaults": {
"ingredient-note": "1 Cup Flour", "ingredient-note": "1 tass jahu",
"step-text": "Recipe steps as well as other fields in the recipe page support markdown syntax.\n\n**Add a link**\n\n[My Link](https://demo.mealie.io)\n" "step-text": "Retsepti sammud nagu ka teised väljad retsepti lehed toetavad Markdown-i süntaksit\n\n** Lisa link **\n[Minu link](https://demo.mealie.io)"
}, },
"servings-text": { "servings-text": {
"makes": "Makes", "makes": "Teeb",
"serves": "Serves", "serves": "Jätkub",
"serving": "Serving", "serving": "Protsion",
"servings": "Servings", "servings": "Portsioneid",
"yield": "Yield", "yield": "Saagikus",
"yields": "Yields" "yields": "Annab saagiks"
} }
}, },
"mealplan": { "mealplan": {
"no-recipes-match-your-rules": "No recipes match your rules" "no-recipes-match-your-rules": "Ükski retsept ei vasta sinu reeglitele"
}, },
"user": { "user": {
"user-updated": "User updated", "user-updated": "Kasutaja uuendatud",
"password-updated": "Password updated", "password-updated": "Salasõna uuendatud",
"invalid-current-password": "Invalid current password", "invalid-current-password": "Mittesobiv praegune salasõna",
"ldap-update-password-unavailable": "Unable to update password, user is controlled by LDAP" "ldap-update-password-unavailable": "Ei saanud uuendada salasõna, kasutajat kontrollib LDAP"
}, },
"group": { "group": {
"report-deleted": "Report deleted." "report-deleted": "Raport kustutatud."
}, },
"exceptions": { "exceptions": {
"permission_denied": "You do not have permission to perform this action", "permission_denied": "Sul puuduvad selle toimingu tegemiseks õigused",
"no-entry-found": "The requested resource was not found", "no-entry-found": "Soovitud ressurssi ei leitud",
"integrity-error": "Database integrity error", "integrity-error": "Tõrge andmebaasi terviklikkuses",
"username-conflict-error": "This username is already taken", "username-conflict-error": "See kasutajanimi on juba kasutusel",
"email-conflict-error": "This email is already in use" "email-conflict-error": "See e-maili aadress on juba kasutuses"
}, },
"notifications": { "notifications": {
"generic-created": "{name} was created", "generic-created": "{name} loodud",
"generic-updated": "{name} was updated", "generic-updated": "{name} uuendatud",
"generic-created-with-url": "{name} has been created, {url}", "generic-created-with-url": "{name} loodud, {url}",
"generic-updated-with-url": "{name} has been updated, {url}", "generic-updated-with-url": "{name} uuendatud, {url}",
"generic-duplicated": "{name} has been duplicated", "generic-duplicated": "{name} on duplitseeritud",
"generic-deleted": "{name} has been deleted" "generic-deleted": "{name} kustutatud"
}, },
"datetime": { "datetime": {
"year": "year|years", "year": "aasta|aastad",
"day": "day|days", "day": "päev|päevad",
"hour": "hour|hours", "hour": "tund|tunnid",
"minute": "minute|minutes", "minute": "minut|minutid",
"second": "second|seconds", "second": "sekund|sekundid",
"millisecond": "millisecond|milliseconds", "millisecond": "millisekund|millisekundid",
"microsecond": "microsecond|microseconds" "microsecond": "mikrosekund|mikrosekundid"
}, },
"emails": { "emails": {
"password": { "password": {
"subject": "Mealie Forgot Password", "subject": "Mealie salasõna unustatud",
"header_text": "Forgot Password", "header_text": "Unustasid salasõna",
"message_top": "You have requested to reset your password.", "message_top": "Oled palunud salasõna lähtestamist",
"message_bottom": "Please click the button above to reset your password.", "message_bottom": "Kliki alloleval nupul, et lähtestada oma salasõna",
"button_text": "Reset Password" "button_text": "Lähtesta salasõna"
}, },
"invitation": { "invitation": {
"subject": "Invitation to join Mealie", "subject": "Kutse Mealiega liitumiseks",
"header_text": "You're Invited!", "header_text": "Said kutse!",
"message_top": "You have been invited to join Mealie.", "message_top": "Sind on kutsutud liituma Mealiega.",
"message_bottom": "Please click the button above to accept the invitation.", "message_bottom": "Palun vajuta allolevat nuppu, et võtta vastu kutse.",
"button_text": "Accept Invitation" "button_text": "Võta kutse vastu"
}, },
"test": { "test": {
"subject": "Mealie Test Email", "subject": "Mealie test-email",
"header_text": "Test Email", "header_text": "Test-email",
"message_top": "This is a test email.", "message_top": "See on test-email",
"message_bottom": "Please click the button above to test the email.", "message_bottom": "Palun vajuta allolevat nuppu, et testida emaili.",
"button_text": "Open Mealie" "button_text": "Ava Mealie"
} }
} }
} }

View file

@ -4,16 +4,16 @@
}, },
"recipe": { "recipe": {
"unique-name-error": "Oppskriftsnavn må være unike", "unique-name-error": "Oppskriftsnavn må være unike",
"recipe-created": "Recipe Created", "recipe-created": "Oppskrift opprettet",
"recipe-defaults": { "recipe-defaults": {
"ingredient-note": "1 kopp mel", "ingredient-note": "1 kopp mel",
"step-text": "Steg i oppskrifter og andre felter på siden støtter markdown syntax.\n\n**Legg til en link**\n\n[Min link](https://demo.mealie.io)\n" "step-text": "Steg i oppskrifter og andre felter på siden støtter markdown syntax.\n\n**Legg til en link**\n\n[Min link](https://demo.mealie.io)\n"
}, },
"servings-text": { "servings-text": {
"makes": "Makes", "makes": "Gir",
"serves": "Serves", "serves": "Nok til",
"serving": "Serving", "serving": "porsjon",
"servings": "Servings", "servings": "porsjoner",
"yield": "Yield", "yield": "Yield",
"yields": "Yields" "yields": "Yields"
} }

View file

@ -3,7 +3,7 @@ from dataclasses import dataclass
from logging import Logger from logging import Logger
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image, ImageOps
from pillow_heif import register_avif_opener, register_heif_opener from pillow_heif import register_avif_opener, register_heif_opener
register_heif_opener() register_heif_opener()
@ -80,6 +80,7 @@ class PillowMinifier(ABCMinifier):
""" """
img = Image.open(image_file) img = Image.open(image_file)
img = ImageOps.exif_transpose(img)
if img.mode not in image_format.modes: if img.mode not in image_format.modes:
img = img.convert(image_format.modes[0]) img = img.convert(image_format.modes[0])
@ -142,6 +143,7 @@ class PillowMinifier(ABCMinifier):
self._logger.info(f"{image_file.name} already minified") self._logger.info(f"{image_file.name} already minified")
else: else:
img = Image.open(image_file) img = Image.open(image_file)
img = ImageOps.exif_transpose(img)
tiny_image = PillowMinifier.crop_center(img) tiny_image = PillowMinifier.crop_center(img)
tiny_image.save(tiny_dest, WEBP.format, quality=70) tiny_image.save(tiny_dest, WEBP.format, quality=70)
self._logger.info("Tiny image saved") self._logger.info("Tiny image saved")

View file

@ -11,6 +11,7 @@ from mealie.db.models.group.preferences import GroupPreferencesModel
from mealie.db.models.household.cookbook import CookBook from mealie.db.models.household.cookbook import CookBook
from mealie.db.models.household.events import GroupEventNotifierModel from mealie.db.models.household.events import GroupEventNotifierModel
from mealie.db.models.household.household import Household from mealie.db.models.household.household import Household
from mealie.db.models.household.household_to_recipe import HouseholdToRecipe
from mealie.db.models.household.invite_tokens import GroupInviteToken from mealie.db.models.household.invite_tokens import GroupInviteToken
from mealie.db.models.household.mealplan import GroupMealPlan, GroupMealPlanRules from mealie.db.models.household.mealplan import GroupMealPlan, GroupMealPlanRules
from mealie.db.models.household.preferences import HouseholdPreferencesModel from mealie.db.models.household.preferences import HouseholdPreferencesModel
@ -37,7 +38,7 @@ from mealie.db.models.users.password_reset import PasswordResetModel
from mealie.db.models.users.user_to_recipe import UserToRecipe from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.repos.repository_cookbooks import RepositoryCookbooks from mealie.repos.repository_cookbooks import RepositoryCookbooks
from mealie.repos.repository_foods import RepositoryFood from mealie.repos.repository_foods import RepositoryFood
from mealie.repos.repository_household import RepositoryHousehold from mealie.repos.repository_household import RepositoryHousehold, RepositoryHouseholdRecipes
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
from mealie.repos.repository_units import RepositoryUnit from mealie.repos.repository_units import RepositoryUnit
from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.cookbook.cookbook import ReadCookBook
@ -52,7 +53,7 @@ from mealie.schema.household.group_shopping_list import (
ShoppingListOut, ShoppingListOut,
ShoppingListRecipeRefOut, ShoppingListRecipeRefOut,
) )
from mealie.schema.household.household import HouseholdInDB from mealie.schema.household.household import HouseholdInDB, HouseholdRecipeOut
from mealie.schema.household.household_preferences import ReadHouseholdPreferences from mealie.schema.household.household_preferences import ReadHouseholdPreferences
from mealie.schema.household.invite_token import ReadInviteToken from mealie.schema.household.invite_token import ReadInviteToken
from mealie.schema.household.webhook import ReadWebhook from mealie.schema.household.webhook import ReadWebhook
@ -231,6 +232,17 @@ class AllRepositories:
household_id=self.household_id, household_id=self.household_id,
) )
@cached_property
def household_recipes(self) -> RepositoryHouseholdRecipes:
return RepositoryHouseholdRecipes(
self.session,
PK_ID,
HouseholdToRecipe,
HouseholdRecipeOut,
group_id=self.group_id,
household_id=self.household_id,
)
@cached_property @cached_property
def cookbooks(self) -> RepositoryCookbooks: def cookbooks(self) -> RepositoryCookbooks:
return RepositoryCookbooks( return RepositoryCookbooks(

View file

@ -8,7 +8,7 @@ from typing import Any, Generic, TypeVar
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import UUID4, BaseModel from pydantic import UUID4, BaseModel
from sqlalchemy import Select, case, delete, func, nulls_first, nulls_last, select from sqlalchemy import ColumnElement, Select, case, delete, func, nulls_first, nulls_last, select
from sqlalchemy.orm import InstrumentedAttribute from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from sqlalchemy.sql import sqltypes from sqlalchemy.sql import sqltypes
@ -69,6 +69,10 @@ class RepositoryGeneric(Generic[Schema, Model]):
def household_id(self) -> UUID4 | None: def household_id(self) -> UUID4 | None:
return self._household_id return self._household_id
@property
def column_aliases(self) -> dict[str, ColumnElement]:
return {}
def _random_seed(self) -> str: def _random_seed(self) -> str:
return str(datetime.now(tz=UTC)) return str(datetime.now(tz=UTC))
@ -356,7 +360,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
if pagination.query_filter: if pagination.query_filter:
try: try:
query_filter_builder = QueryFilterBuilder(pagination.query_filter) query_filter_builder = QueryFilterBuilder(pagination.query_filter)
query = query_filter_builder.filter_query(query, model=self.model) query = query_filter_builder.filter_query(query, model=self.model, column_aliases=self.column_aliases)
except ValueError as e: except ValueError as e:
self.logger.error(e) self.logger.error(e)
@ -394,6 +398,8 @@ class RepositoryGeneric(Generic[Schema, Model]):
order_dir: OrderDirection, order_dir: OrderDirection,
order_by_null: OrderByNullPosition | None, order_by_null: OrderByNullPosition | None,
) -> Select: ) -> Select:
order_attr = self.column_aliases.get(order_attr.key, order_attr)
# queries handle uppercase and lowercase differently, which is undesirable # queries handle uppercase and lowercase differently, which is undesirable
if isinstance(order_attr.type, sqltypes.String): if isinstance(order_attr.type, sqltypes.String):
order_attr = func.lower(order_attr) order_attr = func.lower(order_attr)

View file

@ -8,15 +8,20 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models.household.household import Household from mealie.db.models.household import Household, HouseholdToRecipe
from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.tag import Tag from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users.users import User from mealie.db.models.users.users import User
from mealie.repos.repository_generic import GroupRepositoryGeneric from mealie.repos.repository_generic import GroupRepositoryGeneric, HouseholdRepositoryGeneric
from mealie.schema.household.household import HouseholdCreate, HouseholdInDB, UpdateHousehold from mealie.schema.household import (
from mealie.schema.household.household_statistics import HouseholdStatistics HouseholdCreate,
HouseholdInDB,
HouseholdRecipeOut,
HouseholdStatistics,
UpdateHousehold,
)
class RepositoryHousehold(GroupRepositoryGeneric[HouseholdInDB, Household]): class RepositoryHousehold(GroupRepositoryGeneric[HouseholdInDB, Household]):
@ -101,3 +106,15 @@ class RepositoryHousehold(GroupRepositoryGeneric[HouseholdInDB, Household]):
total_tags=model_count(Tag, filter_household=False), total_tags=model_count(Tag, filter_household=False),
total_tools=model_count(Tool, filter_household=False), total_tools=model_count(Tool, filter_household=False),
) )
class RepositoryHouseholdRecipes(HouseholdRepositoryGeneric[HouseholdRecipeOut, HouseholdToRecipe]):
def get_by_recipe(self, recipe_id: UUID4) -> HouseholdRecipeOut | None:
if not self.household_id:
raise Exception("household_id not set")
stmt = select(HouseholdToRecipe).filter(
HouseholdToRecipe.household_id == self.household_id, HouseholdToRecipe.recipe_id == recipe_id
)
result = self.session.execute(stmt).scalars().one_or_none()
return None if result is None else self.schema.model_validate(result)

View file

@ -11,25 +11,22 @@ from slugify import slugify
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from mealie.db.models.household.household import Household from mealie.db.models.household import Household, HouseholdToRecipe
from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.ingredient import IngredientFoodModel, RecipeIngredientModel from mealie.db.models.recipe.ingredient import RecipeIngredientModel, households_to_ingredient_foods
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.settings import RecipeSettings from mealie.db.models.recipe.settings import RecipeSettings
from mealie.db.models.recipe.tag import Tag from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool, recipes_to_tools from mealie.db.models.recipe.tool import Tool, households_to_tools, recipes_to_tools
from mealie.db.models.users.user_to_recipe import UserToRecipe from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.db.models.users.users import User
from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary
from mealie.schema.recipe.recipe_ingredient import IngredientFood from mealie.schema.recipe.recipe_ingredient import IngredientFood
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
from mealie.schema.recipe.recipe_tool import RecipeToolOut from mealie.schema.recipe.recipe_tool import RecipeToolOut
from mealie.schema.response.pagination import ( from mealie.schema.response.pagination import PaginationQuery
OrderByNullPosition,
OrderDirection,
PaginationQuery,
)
from mealie.schema.response.query_filter import QueryFilterBuilder from mealie.schema.response.query_filter import QueryFilterBuilder
from ..db.models._model_base import SqlAlchemyBase from ..db.models._model_base import SqlAlchemyBase
@ -39,11 +36,58 @@ from .repository_generic import HouseholdRepositoryGeneric
class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]): class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
user_id: UUID4 | None = None user_id: UUID4 | None = None
@property
def column_aliases(self):
if not self.user_id:
return {}
return {
"last_made": self._get_last_made_col_alias(),
"rating": self._get_rating_col_alias(),
}
def by_user(self: Self, user_id: UUID4) -> Self: def by_user(self: Self, user_id: UUID4) -> Self:
"""Add a user_id to the repo, which will be used to handle recipe ratings""" """Add a user_id to the repo, which will be used to handle recipe ratings and other user-specific data"""
self.user_id = user_id self.user_id = user_id
return self return self
def _get_last_made_col_alias(self) -> sa.ColumnElement | None:
"""Computed last_made which uses `HouseholdToRecipe.last_made` for the user's household, otherwise None"""
user_household_subquery = sa.select(User.household_id).where(User.id == self.user_id).scalar_subquery()
return (
sa.select(HouseholdToRecipe.last_made)
.where(
HouseholdToRecipe.recipe_id == self.model.id,
HouseholdToRecipe.household_id == user_household_subquery,
)
.correlate(self.model)
.scalar_subquery()
)
def _get_rating_col_alias(self) -> sa.ColumnElement | None:
"""Computed rating which uses the user's rating if it exists, otherwise falling back to the recipe's rating"""
effective_rating = sa.case(
(
sa.exists().where(
UserToRecipe.recipe_id == self.model.id,
UserToRecipe.user_id == self.user_id,
UserToRecipe.rating != None, # noqa E711
UserToRecipe.rating > 0,
),
sa.select(sa.func.max(UserToRecipe.rating))
.where(UserToRecipe.recipe_id == self.model.id, UserToRecipe.user_id == self.user_id)
.correlate(self.model)
.scalar_subquery(),
),
else_=sa.case(
(self.model.rating == 0, None),
else_=self.model.rating,
),
)
return sa.cast(effective_rating, sa.Float)
def create(self, document: Recipe) -> Recipe: # type: ignore def create(self, document: Recipe) -> Recipe: # type: ignore
max_retries = 10 max_retries = 10
original_name: str = document.name # type: ignore original_name: str = document.name # type: ignore
@ -103,51 +147,6 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
additional_ids = self.session.execute(sa.select(model.id).filter(model.slug.in_(slugs))).scalars().all() additional_ids = self.session.execute(sa.select(model.id).filter(model.slug.in_(slugs))).scalars().all()
return ids + additional_ids return ids + additional_ids
def add_order_attr_to_query(
self,
query: sa.Select,
order_attr: orm.InstrumentedAttribute,
order_dir: OrderDirection,
order_by_null: OrderByNullPosition | None,
) -> sa.Select:
"""Special handling for ordering recipes by rating"""
column_name = order_attr.key
if column_name != "rating" or not self.user_id:
return super().add_order_attr_to_query(query, order_attr, order_dir, order_by_null)
# calculate the effictive rating for the user by using the user's rating if it exists,
# falling back to the recipe's rating if it doesn't
effective_rating_column_name = "_effective_rating"
query = query.add_columns(
sa.case(
(
sa.exists().where(
UserToRecipe.recipe_id == self.model.id,
UserToRecipe.user_id == self.user_id,
UserToRecipe.rating is not None,
UserToRecipe.rating > 0,
),
sa.select(sa.func.max(UserToRecipe.rating))
.where(UserToRecipe.recipe_id == self.model.id, UserToRecipe.user_id == self.user_id)
.scalar_subquery(),
),
else_=sa.case((self.model.rating == 0, None), else_=self.model.rating),
).label(effective_rating_column_name)
)
order_attr = effective_rating_column_name
if order_dir is OrderDirection.asc:
order_attr = sa.asc(order_attr)
elif order_dir is OrderDirection.desc:
order_attr = sa.desc(order_attr)
if order_by_null is OrderByNullPosition.first:
order_attr = sa.nulls_first(order_attr)
else:
order_attr = sa.nulls_last(order_attr)
return query.order_by(order_attr)
def page_all( # type: ignore def page_all( # type: ignore
self, self,
pagination: PaginationQuery, pagination: PaginationQuery,
@ -207,6 +206,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
# Apply options late, so they do not get used for counting # Apply options late, so they do not get used for counting
q = q.options(*RecipeSummary.loader_options()) q = q.options(*RecipeSummary.loader_options())
try: try:
self.logger.debug(f"Recipe Pagination Query: {pagination_result}")
data = self.session.execute(q).scalars().unique().all() data = self.session.execute(q).scalars().unique().all()
except Exception as e: except Exception as e:
self._log_exception(e) self._log_exception(e)
@ -320,33 +320,34 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
if not params.order_by: if not params.order_by:
params.order_by = "created_at" params.order_by = "created_at"
food_ids_with_on_hand = list(set(food_ids or [])) user_food_ids = list(set(food_ids or []))
tool_ids_with_on_hand = list(set(tool_ids or [])) user_tool_ids = list(set(tool_ids or []))
# preserve the original lists of ids before we add on_hand items # preserve the original lists of ids before we add on_hand items
user_food_ids = food_ids_with_on_hand.copy() food_ids_with_on_hand = user_food_ids.copy()
user_tool_ids = tool_ids_with_on_hand.copy() tool_ids_with_on_hand = user_tool_ids.copy()
if params.include_foods_on_hand: if params.include_foods_on_hand and self.user_id:
foods_on_hand_query = sa.select(IngredientFoodModel.id).filter( foods_on_hand_query = (
IngredientFoodModel.on_hand == True, # noqa: E712 - required for SQLAlchemy comparison sa.select(households_to_ingredient_foods.c.food_id)
sa.not_(IngredientFoodModel.id.in_(food_ids_with_on_hand)), .join(User, households_to_ingredient_foods.c.household_id == User.household_id)
.filter(
sa.not_(households_to_ingredient_foods.c.food_id.in_(food_ids_with_on_hand)),
User.id == self.user_id,
)
) )
if self.group_id:
foods_on_hand_query = foods_on_hand_query.filter(IngredientFoodModel.group_id == self.group_id)
foods_on_hand = self.session.execute(foods_on_hand_query).scalars().all() foods_on_hand = self.session.execute(foods_on_hand_query).scalars().all()
food_ids_with_on_hand.extend(foods_on_hand) food_ids_with_on_hand.extend(foods_on_hand)
if params.include_tools_on_hand:
tools_on_hand_query = sa.select(Tool.id).filter(
Tool.on_hand == True, # noqa: E712 - required for SQLAlchemy comparison
sa.not_(
Tool.id.in_(tool_ids_with_on_hand),
),
)
if self.group_id:
tools_on_hand_query = tools_on_hand_query.filter(Tool.group_id == self.group_id)
if params.include_tools_on_hand and self.user_id:
tools_on_hand_query = (
sa.select(households_to_tools.c.tool_id)
.join(User, households_to_tools.c.household_id == User.household_id)
.filter(
sa.not_(households_to_tools.c.tool_id.in_(tool_ids_with_on_hand)),
User.id == self.user_id,
)
)
tools_on_hand = self.session.execute(tools_on_hand_query).scalars().all() tools_on_hand = self.session.execute(tools_on_hand_query).scalars().all()
tool_ids_with_on_hand.extend(tools_on_hand) tool_ids_with_on_hand.extend(tools_on_hand)

View file

@ -63,19 +63,19 @@
"name": "خبز" "name": "خبز"
}, },
"breadfruit": { "breadfruit": {
"name": "breadfruit" "name": "ثمرة الخبز"
}, },
"broccoflower": { "broccoflower": {
"name": "broccoflower" "name": "بروكلي"
}, },
"broccoli": { "broccoli": {
"name": "بروكلي" "name": "بروكلي"
}, },
"broccoli-rabe": { "broccoli-rabe": {
"name": "broccoli rabe" "name": "ربيع البروكلي"
}, },
"broccolini": { "broccolini": {
"name": "broccolini" "name": "بروكوليني"
}, },
"brown-sugar": { "brown-sugar": {
"name": "سكر بني" "name": "سكر بني"
@ -100,13 +100,13 @@
"name": "cactus, edible" "name": "cactus, edible"
}, },
"calabrese": { "calabrese": {
"name": "calabrese" "name": "كالابريس"
}, },
"cane-sugar": { "cane-sugar": {
"name": "cane sugar" "name": "cane sugar"
}, },
"cannabis": { "cannabis": {
"name": "cannabis" "name": "القنب"
}, },
"capsicum": { "capsicum": {
"name": "capsicum" "name": "capsicum"

View file

@ -23,7 +23,7 @@
}, },
"avocado": { "avocado": {
"name": "avokádo", "name": "avokádo",
"plural_name": "avocado" "plural_name": "avodo"
}, },
"bacon": { "bacon": {
"name": "slanina" "name": "slanina"
@ -48,13 +48,13 @@
}, },
"bell-peppers": { "bell-peppers": {
"name": "papriky", "name": "papriky",
"plural_name": "bell peppers" "plural_name": "papriky"
}, },
"blackberries": { "blackberries": {
"name": "ostružiny" "name": "ostružiny"
}, },
"bok-choy": { "bok-choy": {
"name": "bok choy" "name": "brukev čínská"
}, },
"brassicas": { "brassicas": {
"name": "brukev" "name": "brukev"
@ -75,7 +75,7 @@
"name": "rapini" "name": "rapini"
}, },
"broccolini": { "broccolini": {
"name": "broccolini" "name": "brokolice"
}, },
"brown-sugar": { "brown-sugar": {
"name": "hnědý cukr" "name": "hnědý cukr"
@ -94,7 +94,7 @@
}, },
"cabbage": { "cabbage": {
"name": "zelí", "name": "zelí",
"plural_name": "cabbages" "plural_name": "zelí"
}, },
"cactus-edible": { "cactus-edible": {
"name": "kaktus, jedlý" "name": "kaktus, jedlý"
@ -129,7 +129,7 @@
}, },
"cauliflower": { "cauliflower": {
"name": "květák", "name": "květák",
"plural_name": "cauliflowers" "plural_name": "květáky"
}, },
"cayenne-pepper": { "cayenne-pepper": {
"name": "kayenský pepř" "name": "kayenský pepř"
@ -154,7 +154,7 @@
}, },
"chilli-peppers": { "chilli-peppers": {
"name": "čili papričky", "name": "čili papričky",
"plural_name": "chilli peppers" "plural_name": "čili papričky"
}, },
"chinese-leaves": { "chinese-leaves": {
"name": "pekingské zelí" "name": "pekingské zelí"
@ -176,7 +176,7 @@
}, },
"coconut": { "coconut": {
"name": "kokosový ořech", "name": "kokosový ořech",
"plural_name": "coconuts" "plural_name": "kokosové ořechy"
}, },
"coconut-milk": { "coconut-milk": {
"name": "kokosové mléko" "name": "kokosové mléko"
@ -198,7 +198,7 @@
}, },
"corn": { "corn": {
"name": "kukuřice", "name": "kukuřice",
"plural_name": "corns" "plural_name": "kukuřice"
}, },
"corn-syrup": { "corn-syrup": {
"name": "kukuřičný sirup" "name": "kukuřičný sirup"
@ -214,14 +214,14 @@
}, },
"cucumber": { "cucumber": {
"name": "okurka", "name": "okurka",
"plural_name": "cucumbers" "plural_name": "okurky"
}, },
"cumin": { "cumin": {
"name": "římský kmín" "name": "římský kmín"
}, },
"daikon": { "daikon": {
"name": "ředkev bílá", "name": "ředkev bílá",
"plural_name": "daikons" "plural_name": "ředkve bílé"
}, },
"dairy-products-and-dairy-substitutes": { "dairy-products-and-dairy-substitutes": {
"name": "mléčné výrobky a mléčné náhražky" "name": "mléčné výrobky a mléčné náhražky"
@ -240,7 +240,7 @@
}, },
"eggplant": { "eggplant": {
"name": "lilek", "name": "lilek",
"plural_name": "eggplants" "plural_name": "lilky"
}, },
"eggs": { "eggs": {
"name": "vejce", "name": "vejce",
@ -248,7 +248,7 @@
}, },
"endive": { "endive": {
"name": "čekanka", "name": "čekanka",
"plural_name": "endives" "plural_name": "čekanky"
}, },
"fats": { "fats": {
"name": "tuky" "name": "tuky"
@ -260,8 +260,8 @@
"name": "kapradiny" "name": "kapradiny"
}, },
"fiddlehead-fern": { "fiddlehead-fern": {
"name": "fiddlehead fern", "name": "kapradina",
"plural_name": "fiddlehead ferns" "plural_name": "kapradiny"
}, },
"fish": { "fish": {
"name": "ryba" "name": "ryba"
@ -292,7 +292,7 @@
}, },
"garlic": { "garlic": {
"name": "česnek", "name": "česnek",
"plural_name": "garlics" "plural_name": "česneky"
}, },
"gem-squash": { "gem-squash": {
"name": "gem squash" "name": "gem squash"
@ -317,7 +317,7 @@
}, },
"green-onion": { "green-onion": {
"name": "zelená cibule", "name": "zelená cibule",
"plural_name": "green onions" "plural_name": "zelené cibule"
}, },
"heart-of-palm": { "heart-of-palm": {
"name": "palmové srdce", "name": "palmové srdce",
@ -337,7 +337,7 @@
}, },
"jackfruit": { "jackfruit": {
"name": "jackfruit", "name": "jackfruit",
"plural_name": "jackfruits" "plural_name": "jackfruit"
}, },
"jaggery": { "jaggery": {
"name": "jaggery" "name": "jaggery"
@ -368,7 +368,7 @@
}, },
"leek": { "leek": {
"name": "pórek", "name": "pórek",
"plural_name": "leeks" "plural_name": "pórky"
}, },
"legumes": { "legumes": {
"name": "luštěniny" "name": "luštěniny"
@ -384,7 +384,7 @@
}, },
"liver": { "liver": {
"name": "játra", "name": "játra",
"plural_name": "livers" "plural_name": "játra"
}, },
"maize": { "maize": {
"name": "kukuřice" "name": "kukuřice"
@ -403,7 +403,7 @@
}, },
"mushroom": { "mushroom": {
"name": "houba", "name": "houba",
"plural_name": "mushrooms" "plural_name": "houby"
}, },
"mussels": { "mussels": {
"name": "mušle" "name": "mušle"
@ -425,7 +425,7 @@
}, },
"octopuses": { "octopuses": {
"name": "chobotnice", "name": "chobotnice",
"plural_name": "octopuses" "plural_name": "chobotnice"
}, },
"oils": { "oils": {
"name": "oleje" "name": "oleje"
@ -450,7 +450,7 @@
}, },
"oranges": { "oranges": {
"name": "pomeranče", "name": "pomeranče",
"plural_name": "oranges" "plural_name": "pomeranče"
}, },
"oregano": { "oregano": {
"name": "dobromysl" "name": "dobromysl"
@ -469,22 +469,22 @@
}, },
"parsnip": { "parsnip": {
"name": "pastinák", "name": "pastinák",
"plural_name": "parsnips" "plural_name": "pastináky"
}, },
"pear": { "pear": {
"name": "hruška", "name": "hruška",
"plural_name": "pears" "plural_name": "hrušky"
}, },
"peas": { "peas": {
"name": "hrášek" "name": "hrášek"
}, },
"pepper": { "pepper": {
"name": "pepř", "name": "pepř",
"plural_name": "peppers" "plural_name": "papriky"
}, },
"pineapple": { "pineapple": {
"name": "ananas", "name": "ananas",
"plural_name": "pineapples" "plural_name": "ananasy"
}, },
"plantain": { "plantain": {
"name": "plantýn", "name": "plantýn",
@ -495,7 +495,7 @@
}, },
"potato": { "potato": {
"name": "brambor", "name": "brambor",
"plural_name": "potatoes" "plural_name": "brambory"
}, },
"poultry": { "poultry": {
"name": "drůbež" "name": "drůbež"
@ -505,14 +505,14 @@
}, },
"pumpkin": { "pumpkin": {
"name": "dýně", "name": "dýně",
"plural_name": "pumpkins" "plural_name": "dýně"
}, },
"pumpkin-seeds": { "pumpkin-seeds": {
"name": "dýňová semínka" "name": "dýňová semínka"
}, },
"radish": { "radish": {
"name": "ředkvička", "name": "ředkvička",
"plural_name": "radishes" "plural_name": "ředkvičky"
}, },
"raw-sugar": { "raw-sugar": {
"name": "hnědý cukr" "name": "hnědý cukr"
@ -543,7 +543,7 @@
}, },
"scallion": { "scallion": {
"name": "jarní cibulka", "name": "jarní cibulka",
"plural_name": "scallions" "plural_name": "jarní cibulky"
}, },
"seafood": { "seafood": {
"name": "mořské plody" "name": "mořské plody"
@ -556,7 +556,7 @@
}, },
"shallot": { "shallot": {
"name": "šalotka", "name": "šalotka",
"plural_name": "shallots" "plural_name": "šalotky"
}, },
"skate": { "skate": {
"name": "rejnok" "name": "rejnok"
@ -585,7 +585,7 @@
}, },
"spring-onion": { "spring-onion": {
"name": "jarní cibulka", "name": "jarní cibulka",
"plural_name": "spring onions" "plural_name": "jarní cibulky"
}, },
"squash": { "squash": {
"name": "dýně", "name": "dýně",
@ -611,12 +611,12 @@
"name": "cukr krupice" "name": "cukr krupice"
}, },
"sweet-potato": { "sweet-potato": {
"name": "sladká brambora", "name": "batát",
"plural_name": "sweet potatoes" "plural_name": "batáty"
}, },
"sweetcorn": { "sweetcorn": {
"name": "kukuřice cukrová", "name": "kukuřice cukrová",
"plural_name": "sweetcorns" "plural_name": "kukuřice cukrová"
}, },
"sweeteners": { "sweeteners": {
"name": "sladidla" "name": "sladidla"
@ -626,14 +626,14 @@
}, },
"taro": { "taro": {
"name": "kolokázie jedlá", "name": "kolokázie jedlá",
"plural_name": "taroes" "plural_name": "taro"
}, },
"teff": { "teff": {
"name": "milička abesínska" "name": "milička abesínska"
}, },
"tomato": { "tomato": {
"name": "rajče", "name": "rajče",
"plural_name": "tomatoes" "plural_name": "rajčata"
}, },
"trout": { "trout": {
"name": "pstruh" "name": "pstruh"
@ -650,7 +650,7 @@
}, },
"turnip": { "turnip": {
"name": "tuřín", "name": "tuřín",
"plural_name": "turnips" "plural_name": "tuříny"
}, },
"unrefined-sugar": { "unrefined-sugar": {
"name": "nerafinovaný cukr" "name": "nerafinovaný cukr"
@ -666,11 +666,11 @@
}, },
"watermelon": { "watermelon": {
"name": "vodní meloun", "name": "vodní meloun",
"plural_name": "watermelons" "plural_name": "vodní melouny"
}, },
"white-mushroom": { "white-mushroom": {
"name": "bílý žampion", "name": "bílý žampion",
"plural_name": "white mushrooms" "plural_name": "bílé žampiony"
}, },
"white-sugar": { "white-sugar": {
"name": "bílý cukr" "name": "bílý cukr"
@ -687,6 +687,6 @@
}, },
"zucchini": { "zucchini": {
"name": "cuketa", "name": "cuketa",
"plural_name": "zucchinis" "plural_name": "cukety"
} }
} }

View file

@ -9,7 +9,7 @@
"name": "ansjoser" "name": "ansjoser"
}, },
"apples": { "apples": {
"name": "æbler", "name": "æble",
"plural_name": "æbler" "plural_name": "æbler"
}, },
"artichoke": { "artichoke": {

View file

@ -1,692 +1,692 @@
{ {
"acorn-squash": { "acorn-squash": {
"name": "acorn squash" "name": "tammetõru-kõrvits"
}, },
"alfalfa-sprouts": { "alfalfa-sprouts": {
"name": "alfalfa sprouts" "name": "lutserni idud"
}, },
"anchovies": { "anchovies": {
"name": "anchovies" "name": "anšoovised"
}, },
"apples": { "apples": {
"name": "apple", "name": "õun",
"plural_name": "apples" "plural_name": "õunad"
}, },
"artichoke": { "artichoke": {
"name": "artichoke" "name": "artišokk"
}, },
"arugula": { "arugula": {
"name": "arugula" "name": "rukola"
}, },
"asparagus": { "asparagus": {
"name": "asparagus" "name": "spargel"
}, },
"avocado": { "avocado": {
"name": "avocado", "name": "avokaado",
"plural_name": "avocado" "plural_name": "avokaado"
}, },
"bacon": { "bacon": {
"name": "bacon" "name": "peekon"
}, },
"baking-powder": { "baking-powder": {
"name": "baking powder" "name": "küpsetuspulber"
}, },
"baking-soda": { "baking-soda": {
"name": "baking soda" "name": "söögisooda"
}, },
"baking-sugar": { "baking-sugar": {
"name": "baking sugar" "name": "küpsetussuhkur"
}, },
"bar-sugar": { "bar-sugar": {
"name": "bar sugar" "name": "batooni suhkur"
}, },
"basil": { "basil": {
"name": "basil" "name": "basiilik"
}, },
"beans": { "beans": {
"name": "beans" "name": "oad"
}, },
"bell-peppers": { "bell-peppers": {
"name": "bell peppers", "name": "paprika",
"plural_name": "bell peppers" "plural_name": "paprikad"
}, },
"blackberries": { "blackberries": {
"name": "blackberries" "name": "põldmurakas"
}, },
"bok-choy": { "bok-choy": {
"name": "bok choy" "name": "Hiina lehtnaeris (bok choy)"
}, },
"brassicas": { "brassicas": {
"name": "brassicas" "name": "rüps"
}, },
"bread": { "bread": {
"name": "bread" "name": "leib"
}, },
"breadfruit": { "breadfruit": {
"name": "breadfruit" "name": "hõlmine leivapuu"
}, },
"broccoflower": { "broccoflower": {
"name": "broccoflower" "name": "brokkolill"
}, },
"broccoli": { "broccoli": {
"name": "broccoli" "name": "spargelkapsas"
}, },
"broccoli-rabe": { "broccoli-rabe": {
"name": "broccoli rabe" "name": "rapini"
}, },
"broccolini": { "broccolini": {
"name": "broccolini" "name": "beebi brokkoli"
}, },
"brown-sugar": { "brown-sugar": {
"name": "brown sugar" "name": "pruun suhkur"
}, },
"brussels-sprouts": { "brussels-sprouts": {
"name": "brussels sprouts" "name": "Brüsseli kapsas"
}, },
"butter": { "butter": {
"name": "butter" "name": "või"
}, },
"butternut-pumpkin": { "butternut-pumpkin": {
"name": "butternut pumpkin" "name": "kürbis"
}, },
"butternut-squash": { "butternut-squash": {
"name": "butternut squash" "name": "suvikõrvits"
}, },
"cabbage": { "cabbage": {
"name": "cabbage", "name": "kapsas",
"plural_name": "cabbages" "plural_name": "kapsad"
}, },
"cactus-edible": { "cactus-edible": {
"name": "cactus, edible" "name": "kaktus, söödav"
}, },
"calabrese": { "calabrese": {
"name": "calabrese" "name": "calabrese"
}, },
"cane-sugar": { "cane-sugar": {
"name": "cane sugar" "name": "roosuhkur"
}, },
"cannabis": { "cannabis": {
"name": "cannabis" "name": "kanep"
}, },
"capsicum": { "capsicum": {
"name": "capsicum" "name": "paprika"
}, },
"caraway": { "caraway": {
"name": "caraway" "name": "köömes"
}, },
"carrot": { "carrot": {
"name": "carrot", "name": "porgand",
"plural_name": "carrots" "plural_name": "porgandid"
}, },
"caster-sugar": { "caster-sugar": {
"name": "caster sugar" "name": "caster suhkur"
}, },
"castor-sugar": { "castor-sugar": {
"name": "castor sugar" "name": "castor suhkur"
}, },
"catfish": { "catfish": {
"name": "catfish" "name": "sägaline"
}, },
"cauliflower": { "cauliflower": {
"name": "cauliflower", "name": "lillkapsas",
"plural_name": "cauliflowers" "plural_name": "lillkapsad"
}, },
"cayenne-pepper": { "cayenne-pepper": {
"name": "cayenne pepper" "name": "kajenni pipar"
}, },
"celeriac": { "celeriac": {
"name": "celery root" "name": "selleri juur"
}, },
"celery": { "celery": {
"name": "celery" "name": "seller"
}, },
"cereal-grains": { "cereal-grains": {
"name": "cereal grains" "name": "teravilja terad"
}, },
"chard": { "chard": {
"name": "chard" "name": "mangold"
}, },
"cheese": { "cheese": {
"name": "cheese" "name": "juust"
}, },
"chicory": { "chicory": {
"name": "chicory" "name": "harilik sigur"
}, },
"chilli-peppers": { "chilli-peppers": {
"name": "chilli pepper", "name": "tšillipipar",
"plural_name": "chilli peppers" "plural_name": "tšillipiprad"
}, },
"chinese-leaves": { "chinese-leaves": {
"name": "chinese leaves" "name": "Hiina kapsas"
}, },
"chives": { "chives": {
"name": "chives" "name": "murulauk"
}, },
"chocolate": { "chocolate": {
"name": "chocolate" "name": "šokolaad"
}, },
"cilantro": { "cilantro": {
"name": "cilantro" "name": "koriander"
}, },
"cinnamon": { "cinnamon": {
"name": "cinnamon" "name": "kaneel"
}, },
"clarified-butter": { "clarified-butter": {
"name": "clarified butter" "name": "selitatud või"
}, },
"coconut": { "coconut": {
"name": "coconut", "name": "kookos",
"plural_name": "coconuts" "plural_name": "kookosed"
}, },
"coconut-milk": { "coconut-milk": {
"name": "coconut milk" "name": "kookospiip"
}, },
"cod": { "cod": {
"name": "cod" "name": "tursk"
}, },
"coffee": { "coffee": {
"name": "coffee" "name": "kohvi"
}, },
"collard-greens": { "collard-greens": {
"name": "collard greens" "name": "lehtkapsas"
}, },
"confectioners-sugar": { "confectioners-sugar": {
"name": "confectioners' sugar" "name": "tuhksuhkur"
}, },
"coriander": { "coriander": {
"name": "coriander" "name": "koriander"
}, },
"corn": { "corn": {
"name": "corn", "name": "mais",
"plural_name": "corns" "plural_name": "maisid"
}, },
"corn-syrup": { "corn-syrup": {
"name": "corn syrup" "name": "maisisiirup"
}, },
"cottonseed-oil": { "cottonseed-oil": {
"name": "cottonseed oil" "name": "puuvillaseemeõli"
}, },
"courgette": { "courgette": {
"name": "courgette" "name": "suvikõrvits"
}, },
"cream-of-tartar": { "cream-of-tartar": {
"name": "cream of tartar" "name": "\nkaaliumvesiniktartraat "
}, },
"cucumber": { "cucumber": {
"name": "cucumber", "name": "kurk",
"plural_name": "cucumbers" "plural_name": "kurgid"
}, },
"cumin": { "cumin": {
"name": "cumin" "name": "köömned"
}, },
"daikon": { "daikon": {
"name": "daikon", "name": "jaapani redis",
"plural_name": "daikons" "plural_name": "jaapani redised"
}, },
"dairy-products-and-dairy-substitutes": { "dairy-products-and-dairy-substitutes": {
"name": "dairy products and dairy substitutes" "name": "piimatooted ja piima asendajad"
}, },
"dandelion": { "dandelion": {
"name": "dandelion" "name": "võilill"
}, },
"demerara-sugar": { "demerara-sugar": {
"name": "demerara sugar" "name": "fariinsuhkur"
}, },
"dough": { "dough": {
"name": "dough" "name": "tainas"
}, },
"edible-cactus": { "edible-cactus": {
"name": "edible cactus" "name": "söödav kaktus"
}, },
"eggplant": { "eggplant": {
"name": "eggplant", "name": "baklažaan",
"plural_name": "eggplants" "plural_name": "baklažaani"
}, },
"eggs": { "eggs": {
"name": "egg", "name": "muna",
"plural_name": "eggs" "plural_name": "mune"
}, },
"endive": { "endive": {
"name": "endive", "name": "endiivia",
"plural_name": "endives" "plural_name": "endiiviat"
}, },
"fats": { "fats": {
"name": "fats" "name": "rasvad"
}, },
"fava-beans": { "fava-beans": {
"name": "fava beans" "name": "põlduba"
}, },
"fiddlehead": { "fiddlehead": {
"name": "fiddlehead" "name": "viiulpea"
}, },
"fiddlehead-fern": { "fiddlehead-fern": {
"name": "fiddlehead fern", "name": "viiulpea roheline",
"plural_name": "fiddlehead ferns" "plural_name": "viiulpea rohelised"
}, },
"fish": { "fish": {
"name": "fish" "name": "kala"
}, },
"five-spice-powder": { "five-spice-powder": {
"name": "five spice powder" "name": "viievürtsipulber"
}, },
"flour": { "flour": {
"name": "flour" "name": "jahu"
}, },
"frisee": { "frisee": {
"name": "frisee" "name": "endiiviasigur"
}, },
"fructose": { "fructose": {
"name": "fructose" "name": "fruktoos"
}, },
"fruit": { "fruit": {
"name": "fruit" "name": "puuvili"
}, },
"fruit-sugar": { "fruit-sugar": {
"name": "fruit sugar" "name": "puuviljasuhkur"
}, },
"ful": { "ful": {
"name": "ful" "name": "l"
}, },
"garam-masala": { "garam-masala": {
"name": "garam masala" "name": "garam masala"
}, },
"garlic": { "garlic": {
"name": "garlic", "name": "küüslauk",
"plural_name": "garlics" "plural_name": "küüslauku"
}, },
"gem-squash": { "gem-squash": {
"name": "gem squash" "name": "kalliskivikõrvits"
}, },
"ghee": { "ghee": {
"name": "ghee" "name": "ghee"
}, },
"giblets": { "giblets": {
"name": "giblets" "name": "rups"
}, },
"ginger": { "ginger": {
"name": "ginger" "name": "ingver"
}, },
"grains": { "grains": {
"name": "grains" "name": "teraviljad"
}, },
"granulated-sugar": { "granulated-sugar": {
"name": "granulated sugar" "name": "granuleeritud suhkur"
}, },
"grape-seed-oil": { "grape-seed-oil": {
"name": "grape seed oil" "name": "viinamarjaõli"
}, },
"green-onion": { "green-onion": {
"name": "green onion", "name": "roheline sibul",
"plural_name": "green onions" "plural_name": "rohelist sibulat"
}, },
"heart-of-palm": { "heart-of-palm": {
"name": "heart of palm", "name": "palmisüda",
"plural_name": "heart of palms" "plural_name": "palmisüdant"
}, },
"hemp": { "hemp": {
"name": "hemp" "name": "kanep"
}, },
"herbs": { "herbs": {
"name": "herbs" "name": "maitsetaimed"
}, },
"honey": { "honey": {
"name": "honey" "name": "mesi"
}, },
"isomalt": { "isomalt": {
"name": "isomalt" "name": "isomalt"
}, },
"jackfruit": { "jackfruit": {
"name": "jackfruit", "name": "viigumari",
"plural_name": "jackfruits" "plural_name": "viigimarja"
}, },
"jaggery": { "jaggery": {
"name": "jaggery" "name": "roosuhkur"
}, },
"jams": { "jams": {
"name": "jams" "name": "moosid"
}, },
"jellies": { "jellies": {
"name": "jellies" "name": "tarretised"
}, },
"jerusalem-artichoke": { "jerusalem-artichoke": {
"name": "jerusalem artichoke" "name": "jeruusalemma artišokk"
}, },
"jicama": { "jicama": {
"name": "jicama" "name": "söödav ubajuur"
}, },
"kale": { "kale": {
"name": "kale" "name": "lehtkapsas"
}, },
"kohlrabi": { "kohlrabi": {
"name": "kohlrabi" "name": "nuikapsas"
}, },
"kumara": { "kumara": {
"name": "kumara" "name": "bataat"
}, },
"leavening-agents": { "leavening-agents": {
"name": "leavening agents" "name": "kergitusaine"
}, },
"leek": { "leek": {
"name": "leek", "name": "porrulauk",
"plural_name": "leeks" "plural_name": "porrulauku"
}, },
"legumes": { "legumes": {
"name": "legumes" "name": "kaunviljad"
}, },
"lemongrass": { "lemongrass": {
"name": "lemongrass" "name": "sidrunhein"
}, },
"lentils": { "lentils": {
"name": "lentils" "name": "läätsed"
}, },
"lettuce": { "lettuce": {
"name": "lettuce" "name": "aedsalat"
}, },
"liver": { "liver": {
"name": "liver", "name": "maks",
"plural_name": "livers" "plural_name": "maksa"
}, },
"maize": { "maize": {
"name": "maize" "name": "mais"
}, },
"maple-syrup": { "maple-syrup": {
"name": "maple syrup" "name": "vahtrasiirup"
}, },
"meat": { "meat": {
"name": "meat" "name": "liha"
}, },
"milk": { "milk": {
"name": "milk" "name": "piim"
}, },
"mortadella": { "mortadella": {
"name": "mortadella" "name": "mortadella"
}, },
"mushroom": { "mushroom": {
"name": "mushroom", "name": "seen",
"plural_name": "mushrooms" "plural_name": "seent"
}, },
"mussels": { "mussels": {
"name": "mussels" "name": "rannakarp"
}, },
"nanaimo-bar-mix": { "nanaimo-bar-mix": {
"name": "nanaimo bar mix" "name": "nanaimo baari segu"
}, },
"nori": { "nori": {
"name": "nori" "name": "norileht"
}, },
"nutmeg": { "nutmeg": {
"name": "nutmeg" "name": "muskaat"
}, },
"nutritional-yeast-flakes": { "nutritional-yeast-flakes": {
"name": "nutritional yeast flakes" "name": "toitvad pärmihelbed"
}, },
"nuts": { "nuts": {
"name": "nuts" "name": "pähklid"
}, },
"octopuses": { "octopuses": {
"name": "octopus", "name": "kaheksajalg",
"plural_name": "octopuses" "plural_name": "kaheksajalad"
}, },
"oils": { "oils": {
"name": "oils" "name": "õlid"
}, },
"okra": { "okra": {
"name": "okra" "name": "muskushibisk (okra)"
}, },
"olive": { "olive": {
"name": "olive" "name": "oliiv"
}, },
"olive-oil": { "olive-oil": {
"name": "olive oil" "name": "oliiviõli"
}, },
"onion": { "onion": {
"name": "onion" "name": "sibul"
}, },
"onion-family": { "onion-family": {
"name": "onion family" "name": "sibulalised"
}, },
"orange-blossom-water": { "orange-blossom-water": {
"name": "orange blossom water" "name": "apelsiniõie vesi"
}, },
"oranges": { "oranges": {
"name": "orange", "name": "apelsin",
"plural_name": "oranges" "plural_name": "apelsinid"
}, },
"oregano": { "oregano": {
"name": "oregano" "name": "oregaano"
}, },
"oysters": { "oysters": {
"name": "oysters" "name": "austrid"
}, },
"panch-puran": { "panch-puran": {
"name": "panch puran" "name": "Pānch Phoron"
}, },
"paprika": { "paprika": {
"name": "paprika" "name": "punapipar"
}, },
"parsley": { "parsley": {
"name": "parsley" "name": "petersell"
}, },
"parsnip": { "parsnip": {
"name": "parsnip", "name": "pastinaak",
"plural_name": "parsnips" "plural_name": "pastinaaki"
}, },
"pear": { "pear": {
"name": "pear", "name": "pirn",
"plural_name": "pears" "plural_name": "pirni"
}, },
"peas": { "peas": {
"name": "peas" "name": "hernes"
}, },
"pepper": { "pepper": {
"name": "pepper", "name": "pipar",
"plural_name": "peppers" "plural_name": "pipart"
}, },
"pineapple": { "pineapple": {
"name": "pineapple", "name": "ananass",
"plural_name": "pineapples" "plural_name": "ananassi"
}, },
"plantain": { "plantain": {
"name": "plantain", "name": "praebanaan",
"plural_name": "plantains" "plural_name": "praebanaani"
}, },
"poppy-seeds": { "poppy-seeds": {
"name": "poppy seeds" "name": "mooniseemned"
}, },
"potato": { "potato": {
"name": "potato", "name": "kartul",
"plural_name": "potatoes" "plural_name": "kartulit"
}, },
"poultry": { "poultry": {
"name": "poultry" "name": "linnuliha"
}, },
"powdered-sugar": { "powdered-sugar": {
"name": "powdered sugar" "name": "tuhksuhkur"
}, },
"pumpkin": { "pumpkin": {
"name": "pumpkin", "name": "kõrvits",
"plural_name": "pumpkins" "plural_name": "kõrvitsat"
}, },
"pumpkin-seeds": { "pumpkin-seeds": {
"name": "pumpkin seeds" "name": "kõrvitsaseemned"
}, },
"radish": { "radish": {
"name": "radish", "name": "redis",
"plural_name": "radishes" "plural_name": "redist"
}, },
"raw-sugar": { "raw-sugar": {
"name": "raw sugar" "name": "pruunsuhkur"
}, },
"refined-sugar": { "refined-sugar": {
"name": "refined sugar" "name": "rafineeritud suhkur"
}, },
"rice": { "rice": {
"name": "rice" "name": "riis"
}, },
"rice-flour": { "rice-flour": {
"name": "rice flour" "name": "riisijahu"
}, },
"rock-sugar": { "rock-sugar": {
"name": "rock sugar" "name": "kivisuhkur"
}, },
"rum": { "rum": {
"name": "rum" "name": "rumm"
}, },
"salmon": { "salmon": {
"name": "salmon" "name": "lõhe"
}, },
"salt": { "salt": {
"name": "salt" "name": "sool"
}, },
"salt-cod": { "salt-cod": {
"name": "salt cod" "name": "soolatursk"
}, },
"scallion": { "scallion": {
"name": "scallion", "name": "roheline sibul",
"plural_name": "scallions" "plural_name": "rohelised sibulad"
}, },
"seafood": { "seafood": {
"name": "seafood" "name": "mereannid"
}, },
"seeds": { "seeds": {
"name": "seeds" "name": "seemned"
}, },
"sesame-seeds": { "sesame-seeds": {
"name": "sesame seeds" "name": "seesamiseemned"
}, },
"shallot": { "shallot": {
"name": "shallot", "name": "šalottsibul",
"plural_name": "shallots" "plural_name": "šalottsibulad"
}, },
"skate": { "skate": {
"name": "skate" "name": "raikala"
}, },
"soda": { "soda": {
"name": "soda" "name": "sooda"
}, },
"soda-baking": { "soda-baking": {
"name": "soda, baking" "name": "söögisooda"
}, },
"soybean": { "soybean": {
"name": "soybean" "name": "sojauba"
}, },
"spaghetti-squash": { "spaghetti-squash": {
"name": "spaghetti squash", "name": "taimsed spagetid",
"plural_name": "spaghetti squashes" "plural_name": "taimsed spagetid"
}, },
"speck": { "speck": {
"name": "speck" "name": "soolaliha"
}, },
"spices": { "spices": {
"name": "spices" "name": "vürtsid"
}, },
"spinach": { "spinach": {
"name": "spinach" "name": "spinat"
}, },
"spring-onion": { "spring-onion": {
"name": "spring onion", "name": "roheline sibul",
"plural_name": "spring onions" "plural_name": "rohelised sibulad"
}, },
"squash": { "squash": {
"name": "squash", "name": "kabatšokk",
"plural_name": "squashes" "plural_name": "kabatšokid"
}, },
"squash-family": { "squash-family": {
"name": "squash family" "name": "kõrvitsalised"
}, },
"stockfish": { "stockfish": {
"name": "stockfish" "name": "kuivatatud kala"
}, },
"sugar": { "sugar": {
"name": "sugar" "name": "suhkur"
}, },
"sunchoke": { "sunchoke": {
"name": "sunchoke", "name": "maapirn",
"plural_name": "sunchokes" "plural_name": "maapirni"
}, },
"sunflower-seeds": { "sunflower-seeds": {
"name": "sunflower seeds" "name": "päevalilleseemned"
}, },
"superfine-sugar": { "superfine-sugar": {
"name": "superfine sugar" "name": "tuhksuhkur"
}, },
"sweet-potato": { "sweet-potato": {
"name": "sweet potato", "name": "bataat",
"plural_name": "sweet potatoes" "plural_name": "bataadid"
}, },
"sweetcorn": { "sweetcorn": {
"name": "sweetcorn", "name": "suhkrumais",
"plural_name": "sweetcorns" "plural_name": "suhkrumaisid"
}, },
"sweeteners": { "sweeteners": {
"name": "sweeteners" "name": "magusained"
}, },
"tahini": { "tahini": {
"name": "tahini" "name": "tahhiini"
}, },
"taro": { "taro": {
"name": "taro", "name": "tarokk",
"plural_name": "taroes" "plural_name": "tarokid"
}, },
"teff": { "teff": {
"name": "teff" "name": "teff"
}, },
"tomato": { "tomato": {
"name": "tomato", "name": "tomat",
"plural_name": "tomatoes" "plural_name": "tomatid"
}, },
"trout": { "trout": {
"name": "trout" "name": "forell"
}, },
"tubers": { "tubers": {
"name": "tuber", "name": "mugul",
"plural_name": "tubers" "plural_name": "mugulad"
}, },
"tuna": { "tuna": {
"name": "tuna" "name": "tuunikala"
}, },
"turbanado-sugar": { "turbanado-sugar": {
"name": "turbanado sugar" "name": "Naturaalne pruunsuhkur"
}, },
"turnip": { "turnip": {
"name": "turnip", "name": "naeris",
"plural_name": "turnips" "plural_name": "naerid"
}, },
"unrefined-sugar": { "unrefined-sugar": {
"name": "unrefined sugar" "name": "rafineerimata suhkur"
}, },
"vanilla": { "vanilla": {
"name": "vanilla" "name": "vanilje"
}, },
"vegetables": { "vegetables": {
"name": "vegetables" "name": "Köögiviljad"
}, },
"watercress": { "watercress": {
"name": "watercress" "name": "Ürt-allikkerss"
}, },
"watermelon": { "watermelon": {
"name": "watermelon", "name": "arbuus",
"plural_name": "watermelons" "plural_name": "arbuusid"
}, },
"white-mushroom": { "white-mushroom": {
"name": "white mushroom", "name": "valge seen",
"plural_name": "white mushrooms" "plural_name": "valged seened"
}, },
"white-sugar": { "white-sugar": {
"name": "white sugar" "name": "valge suhkur"
}, },
"xanthan-gum": { "xanthan-gum": {
"name": "xanthan gum" "name": "ksantaankummi kiudaine"
}, },
"yam": { "yam": {
"name": "yam", "name": "jamss",
"plural_name": "yams" "plural_name": "jamssid"
}, },
"yeast": { "yeast": {
"name": "yeast" "name": "pärm"
}, },
"zucchini": { "zucchini": {
"name": "zucchini", "name": "suvikõrvits",
"plural_name": "zucchinis" "plural_name": "suvikõrvitsad"
} }
} }

View file

@ -403,7 +403,7 @@
}, },
"mushroom": { "mushroom": {
"name": "sopp", "name": "sopp",
"plural_name": "mushrooms" "plural_name": "sopper"
}, },
"mussels": { "mussels": {
"name": "blåskjell" "name": "blåskjell"
@ -450,7 +450,7 @@
}, },
"oranges": { "oranges": {
"name": "appelsin", "name": "appelsin",
"plural_name": "oranges" "plural_name": "appelsiner"
}, },
"oregano": { "oregano": {
"name": "oregano" "name": "oregano"
@ -462,7 +462,7 @@
"name": "panch phoron" "name": "panch phoron"
}, },
"paprika": { "paprika": {
"name": "paprika" "name": "paprikakrydder"
}, },
"parsley": { "parsley": {
"name": "persille" "name": "persille"
@ -473,7 +473,7 @@
}, },
"pear": { "pear": {
"name": "pære", "name": "pære",
"plural_name": "pears" "plural_name": "pærer"
}, },
"peas": { "peas": {
"name": "erter" "name": "erter"
@ -484,18 +484,18 @@
}, },
"pineapple": { "pineapple": {
"name": "ananas", "name": "ananas",
"plural_name": "pineapples" "plural_name": "ananaser"
}, },
"plantain": { "plantain": {
"name": "kokebanan", "name": "kokebanan",
"plural_name": "plantains" "plural_name": "kokebananer"
}, },
"poppy-seeds": { "poppy-seeds": {
"name": "valmuefrø" "name": "valmuefrø"
}, },
"potato": { "potato": {
"name": "potet", "name": "potet",
"plural_name": "potatoes" "plural_name": "poteter"
}, },
"poultry": { "poultry": {
"name": "fjærfe" "name": "fjærfe"
@ -505,14 +505,14 @@
}, },
"pumpkin": { "pumpkin": {
"name": "gresskar", "name": "gresskar",
"plural_name": "pumpkins" "plural_name": "gresskar"
}, },
"pumpkin-seeds": { "pumpkin-seeds": {
"name": "gresskarfrø" "name": "gresskarfrø"
}, },
"radish": { "radish": {
"name": "reddik", "name": "reddik",
"plural_name": "radishes" "plural_name": "reddiker"
}, },
"raw-sugar": { "raw-sugar": {
"name": "brunt sukker" "name": "brunt sukker"
@ -585,11 +585,11 @@
}, },
"spring-onion": { "spring-onion": {
"name": "vårløk", "name": "vårløk",
"plural_name": "spring onions" "plural_name": "vårløker"
}, },
"squash": { "squash": {
"name": "squash", "name": "squash",
"plural_name": "squashes" "plural_name": "squash"
}, },
"squash-family": { "squash-family": {
"name": "squash familien" "name": "squash familien"
@ -612,11 +612,11 @@
}, },
"sweet-potato": { "sweet-potato": {
"name": "søtpotet", "name": "søtpotet",
"plural_name": "sweet potatoes" "plural_name": "søtpoteter"
}, },
"sweetcorn": { "sweetcorn": {
"name": "sukkermais", "name": "sukkermais",
"plural_name": "sweetcorns" "plural_name": "sukkermais"
}, },
"sweeteners": { "sweeteners": {
"name": "søtningsmiddel" "name": "søtningsmiddel"
@ -633,7 +633,7 @@
}, },
"tomato": { "tomato": {
"name": "tomat", "name": "tomat",
"plural_name": "tomatoes" "plural_name": "tomater"
}, },
"trout": { "trout": {
"name": "ørret" "name": "ørret"
@ -650,7 +650,7 @@
}, },
"turnip": { "turnip": {
"name": "nepe", "name": "nepe",
"plural_name": "turnips" "plural_name": "neper"
}, },
"unrefined-sugar": { "unrefined-sugar": {
"name": "uraffinert sukker" "name": "uraffinert sukker"
@ -666,11 +666,11 @@
}, },
"watermelon": { "watermelon": {
"name": "vannmelon", "name": "vannmelon",
"plural_name": "watermelons" "plural_name": "vannmeloner"
}, },
"white-mushroom": { "white-mushroom": {
"name": "sjampinjonger", "name": "sjampinjong",
"plural_name": "white mushrooms" "plural_name": "sjampinjonger"
}, },
"white-sugar": { "white-sugar": {
"name": "sukker" "name": "sukker"

View file

@ -1,12 +1,12 @@
[ [
{ {
"name": "Produce" "name": "المنتج"
}, },
{ {
"name": "الحبوب" "name": "الحبوب"
}, },
{ {
"name": "الفواكة" "name": "آلفواكه"
}, },
{ {
"name": "الخضراوات" "name": "الخضراوات"
@ -27,10 +27,10 @@
"name": "المعلبات" "name": "المعلبات"
}, },
{ {
"name": "Condiments" "name": "الباهرات"
}, },
{ {
"name": "Confectionary" "name": "الحَلْوَيَات"
}, },
{ {
"name": "منتجات الألبان" "name": "منتجات الألبان"
@ -42,7 +42,7 @@
"name": "الأغذية الصحية" "name": "الأغذية الصحية"
}, },
{ {
"name": "Household" "name": "المنزل"
}, },
{ {
"name": "منتجات اللحوم" "name": "منتجات اللحوم"
@ -54,7 +54,7 @@
"name": "التوابل" "name": "التوابل"
}, },
{ {
"name": "الحلويات" "name": "الحَلْوَيَات"
}, },
{ {
"name": "الكحول" "name": "الكحول"

View file

@ -1,65 +1,65 @@
[ [
{ {
"name": "Produce" "name": "Toode"
}, },
{ {
"name": "Grains" "name": "Teraviljad"
}, },
{ {
"name": "Fruits" "name": "Puuviljad"
}, },
{ {
"name": "Vegetables" "name": "Köögiviljad"
}, },
{ {
"name": "Meat" "name": "Liha"
}, },
{ {
"name": "Seafood" "name": "Mereannid"
}, },
{ {
"name": "Beverages" "name": "Joogid"
}, },
{ {
"name": "Baked Goods" "name": "Küpsetised"
}, },
{ {
"name": "Canned Goods" "name": "Konservid"
}, },
{ {
"name": "Condiments" "name": "Maitseained"
}, },
{ {
"name": "Confectionary" "name": "Kondiitritooted"
}, },
{ {
"name": "Dairy Products" "name": "Piimatooted"
}, },
{ {
"name": "Frozen Foods" "name": "Sügavkülmutatud toidud"
}, },
{ {
"name": "Health Foods" "name": "Tervisetoidud"
}, },
{ {
"name": "Household" "name": "Leibkond"
}, },
{ {
"name": "Meat Products" "name": "Lihatooted"
}, },
{ {
"name": "Snacks" "name": "Snäkid"
}, },
{ {
"name": "Spices" "name": "Vürtsid"
}, },
{ {
"name": "Sweets" "name": "Maiustused"
}, },
{ {
"name": "Alcohol" "name": "Alkohol"
}, },
{ {
"name": "Other" "name": "Muu"
} }
] ]

View file

@ -1,140 +1,140 @@
{ {
"teaspoon": { "teaspoon": {
"name": "ملعقة صغيرة", "name": "ملعقة صغيرة",
"plural_name": "teaspoons", "plural_name": "ملعقة صغيرة",
"description": "", "description": "",
"abbreviation": "ملعقة صغيرة" "abbreviation": "ملعقة صغيرة"
}, },
"tablespoon": { "tablespoon": {
"name": "ملعقة كبيرة", "name": "ملعقة كبيرة",
"plural_name": "tablespoons", "plural_name": "ملعقة كبيرة",
"description": "", "description": "",
"abbreviation": "ملعقة كبيرة" "abbreviation": "ملعقة كبيرة"
}, },
"cup": { "cup": {
"name": "كوب", "name": "كوب",
"plural_name": "cups", "plural_name": "كوب",
"description": "", "description": "",
"abbreviation": "c" "abbreviation": "درجة مئوية"
}, },
"fluid-ounce": { "fluid-ounce": {
"name": "أوقية 200gm", "name": "أونصة سائل",
"plural_name": "fluid ounces", "plural_name": "أونصة سائل",
"description": "", "description": "",
"abbreviation": "أوقية 200gm" "abbreviation": "أونصة سائل"
}, },
"pint": { "pint": {
"name": "نصف لتر", "name": "نصف لتر",
"plural_name": "pints", "plural_name": "نصف لتر",
"description": "", "description": "",
"abbreviation": "نصف لتر" "abbreviation": "نصف لتر"
}, },
"quart": { "quart": {
"name": "الربع", "name": "الربع",
"plural_name": "quarts", "plural_name": "لتر",
"description": "", "description": "",
"abbreviation": "الربع" "abbreviation": "الربع"
}, },
"gallon": { "gallon": {
"name": "جالون", "name": "جالون",
"plural_name": "gallons", "plural_name": "جالون",
"description": "", "description": "",
"abbreviation": "جالون" "abbreviation": "جالون"
}, },
"milliliter": { "milliliter": {
"name": "ميلي لتر", "name": "ميلي لتر",
"plural_name": "milliliters", "plural_name": "مللي لترات",
"description": "", "description": "",
"abbreviation": "مل" "abbreviation": "مل"
}, },
"liter": { "liter": {
"name": "لتر", "name": "لتر",
"plural_name": "liters", "plural_name": "لترات",
"description": "", "description": "",
"abbreviation": "لتر" "abbreviation": "لتر"
}, },
"pound": { "pound": {
"name": "رطل", "name": "رطل",
"plural_name": "pounds", "plural_name": "رطل",
"description": "", "description": "",
"abbreviation": "رطل", "abbreviation": "رطل",
"plural_abbreviation": "lbs" "plural_abbreviation": "رطل"
}, },
"ounce": { "ounce": {
"name": "أوقية / ألأونضه", "name": "أوقية / ألأونضه",
"plural_name": "ounces", "plural_name": "أوقية / ألأونضه",
"description": "", "description": "",
"abbreviation": "أونصة" "abbreviation": "أونصة"
}, },
"gram": { "gram": {
"name": "جرام", "name": "جرام",
"plural_name": "grams", "plural_name": "جرامات",
"description": "", "description": "",
"abbreviation": "غرام" "abbreviation": "غرام"
}, },
"kilogram": { "kilogram": {
"name": "كيلوغرام", "name": "كيلوغرام",
"plural_name": "kilograms", "plural_name": "كيلوغرامات",
"description": "", "description": "",
"abbreviation": "كيلوغرام" "abbreviation": "كيلوغرام"
}, },
"milligram": { "milligram": {
"name": "مليغرام", "name": "مليغرام",
"plural_name": "milligrams", "plural_name": "ميليغرامات",
"description": "", "description": "",
"abbreviation": "مليغرام" "abbreviation": "مليغرام"
}, },
"splash": { "splash": {
"name": "دفقة", "name": "دفقة",
"plural_name": "splashes", "plural_name": "رشه",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"dash": { "dash": {
"name": "اندفاع", "name": "اندفاع",
"plural_name": "dashes", "plural_name": "رشه",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"serving": { "serving": {
"name": "حصة الطعام", "name": "حصة الطعام",
"plural_name": "servings", "plural_name": "حصة طعام",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"head": { "head": {
"name": "رأس", "name": "رأس",
"plural_name": "heads", "plural_name": "رؤوس",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"clove": { "clove": {
"name": "القرنفل", "name": "القرنفل",
"plural_name": "cloves", "plural_name": "القرنفل",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"can": { "can": {
"name": "يمكن", "name": "يمكن",
"plural_name": "cans", "plural_name": "علب",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"bunch": { "bunch": {
"name": "bunch", "name": "باقة",
"plural_name": "bunches", "plural_name": "باقات",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"pack": { "pack": {
"name": "pack", "name": "حُزْمَة",
"plural_name": "packs", "plural_name": "حزمات",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"pinch": { "pinch": {
"name": "pinch", "name": "رشه",
"plural_name": "pinches", "plural_name": "رشاة",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
} }

View file

@ -1,140 +1,140 @@
{ {
"teaspoon": { "teaspoon": {
"name": "čajová lžička", "name": "čajová lžička",
"plural_name": "teaspoons", "plural_name": "čajových lžiček",
"description": "", "description": "",
"abbreviation": "čl" "abbreviation": "čl"
}, },
"tablespoon": { "tablespoon": {
"name": "polévková lžíce", "name": "polévková lžíce",
"plural_name": "tablespoons", "plural_name": "polévkových lžic",
"description": "", "description": "",
"abbreviation": "pl" "abbreviation": "pl"
}, },
"cup": { "cup": {
"name": "šálek", "name": "šálek",
"plural_name": "cups", "plural_name": "šálků",
"description": "", "description": "",
"abbreviation": "c" "abbreviation": "š"
}, },
"fluid-ounce": { "fluid-ounce": {
"name": "dutá unce", "name": "dutá unce",
"plural_name": "fluid ounces", "plural_name": "tekuté unce",
"description": "", "description": "",
"abbreviation": "fl oz" "abbreviation": "fl oz"
}, },
"pint": { "pint": {
"name": "pinta", "name": "pinta",
"plural_name": "pints", "plural_name": "pint",
"description": "", "description": "",
"abbreviation": "pt" "abbreviation": "pt"
}, },
"quart": { "quart": {
"name": "čtvrtka", "name": "čtvrtka",
"plural_name": "quarts", "plural_name": "čtvrtky",
"description": "", "description": "",
"abbreviation": "čtvrtka" "abbreviation": "čtvrtka"
}, },
"gallon": { "gallon": {
"name": "galon", "name": "galon",
"plural_name": "gallons", "plural_name": "galonů",
"description": "", "description": "",
"abbreviation": "gal" "abbreviation": "gal"
}, },
"milliliter": { "milliliter": {
"name": "mililitr", "name": "mililitr",
"plural_name": "milliliters", "plural_name": "mililitrů",
"description": "", "description": "",
"abbreviation": "ml" "abbreviation": "ml"
}, },
"liter": { "liter": {
"name": "litr", "name": "litr",
"plural_name": "liters", "plural_name": "lit",
"description": "", "description": "",
"abbreviation": "l" "abbreviation": "l"
}, },
"pound": { "pound": {
"name": "libra", "name": "libra",
"plural_name": "pounds", "plural_name": "liber",
"description": "", "description": "",
"abbreviation": "lb", "abbreviation": "lb",
"plural_abbreviation": "lbs" "plural_abbreviation": "liber"
}, },
"ounce": { "ounce": {
"name": "unce", "name": "unce",
"plural_name": "ounces", "plural_name": "uncí",
"description": "", "description": "",
"abbreviation": "oz" "abbreviation": "oz"
}, },
"gram": { "gram": {
"name": "gram", "name": "gram",
"plural_name": "grams", "plural_name": "gramů",
"description": "", "description": "",
"abbreviation": "g" "abbreviation": "g"
}, },
"kilogram": { "kilogram": {
"name": "kilogram", "name": "kilogram",
"plural_name": "kilograms", "plural_name": "kilogramů",
"description": "", "description": "",
"abbreviation": "kg" "abbreviation": "kg"
}, },
"milligram": { "milligram": {
"name": "miligram", "name": "miligram",
"plural_name": "milligrams", "plural_name": "miligramů",
"description": "", "description": "",
"abbreviation": "mg" "abbreviation": "mg"
}, },
"splash": { "splash": {
"name": "kapka", "name": "kapka",
"plural_name": "splashes", "plural_name": "kapky",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"dash": { "dash": {
"name": "špetka", "name": "špetka",
"plural_name": "dashes", "plural_name": "špetky",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"serving": { "serving": {
"name": "porce", "name": "porce",
"plural_name": "servings", "plural_name": "porcí",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"head": { "head": {
"name": "hlava", "name": "hlava",
"plural_name": "heads", "plural_name": "hlav",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"clove": { "clove": {
"name": "stroužek", "name": "stroužek",
"plural_name": "cloves", "plural_name": "stroužky",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"can": { "can": {
"name": "plechovka", "name": "plechovka",
"plural_name": "cans", "plural_name": "konzerv",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"bunch": { "bunch": {
"name": "bunch", "name": "svazek",
"plural_name": "bunches", "plural_name": "svazků",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"pack": { "pack": {
"name": "pack", "name": "balení",
"plural_name": "packs", "plural_name": "balení",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"pinch": { "pinch": {
"name": "pinch", "name": "špetka",
"plural_name": "pinches", "plural_name": "špetky",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
} }

View file

@ -73,8 +73,8 @@
"abbreviation": "g" "abbreviation": "g"
}, },
"kilogram": { "kilogram": {
"name": "kilogam", "name": "kilogram",
"plural_name": "kilogam", "plural_name": "kilogram",
"description": "", "description": "",
"abbreviation": "kg" "abbreviation": "kg"
}, },
@ -97,7 +97,7 @@
"abbreviation": "" "abbreviation": ""
}, },
"serving": { "serving": {
"name": "servering", "name": "portion",
"plural_name": "portioner", "plural_name": "portioner",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""

View file

@ -1,140 +1,140 @@
{ {
"teaspoon": { "teaspoon": {
"name": "teaspoon", "name": "teelusikas",
"plural_name": "teaspoons", "plural_name": "teelusikad",
"description": "", "description": "",
"abbreviation": "tsp" "abbreviation": "tl"
}, },
"tablespoon": { "tablespoon": {
"name": "tablespoon", "name": "supilusikas",
"plural_name": "tablespoons", "plural_name": "supilusikad",
"description": "", "description": "",
"abbreviation": "tbsp" "abbreviation": "sl"
}, },
"cup": { "cup": {
"name": "cup", "name": "tass",
"plural_name": "cups", "plural_name": "tassid",
"description": "", "description": "",
"abbreviation": "c" "abbreviation": "tassi"
}, },
"fluid-ounce": { "fluid-ounce": {
"name": "fluid ounce", "name": "vedelikuunts",
"plural_name": "fluid ounces", "plural_name": "vedelikuuntsi",
"description": "", "description": "",
"abbreviation": "fl oz" "abbreviation": "fl oz"
}, },
"pint": { "pint": {
"name": "pint", "name": "pint",
"plural_name": "pints", "plural_name": "pinti",
"description": "", "description": "",
"abbreviation": "pt" "abbreviation": "pt"
}, },
"quart": { "quart": {
"name": "quart", "name": "kvart",
"plural_name": "quarts", "plural_name": "kvarti",
"description": "", "description": "",
"abbreviation": "qt" "abbreviation": "qt"
}, },
"gallon": { "gallon": {
"name": "gallon", "name": "gallon",
"plural_name": "gallons", "plural_name": "gallonit",
"description": "", "description": "",
"abbreviation": "gal" "abbreviation": "gal"
}, },
"milliliter": { "milliliter": {
"name": "milliliter", "name": "milliliiter",
"plural_name": "milliliters", "plural_name": "milliliitrit",
"description": "", "description": "",
"abbreviation": "ml" "abbreviation": "ml"
}, },
"liter": { "liter": {
"name": "liter", "name": "liiter",
"plural_name": "liters", "plural_name": "liitrit",
"description": "", "description": "",
"abbreviation": "l" "abbreviation": "l"
}, },
"pound": { "pound": {
"name": "pound", "name": "nael",
"plural_name": "pounds", "plural_name": "naela",
"description": "", "description": "",
"abbreviation": "lb", "abbreviation": "lb",
"plural_abbreviation": "lbs" "plural_abbreviation": "lbs"
}, },
"ounce": { "ounce": {
"name": "ounce", "name": "unts",
"plural_name": "ounces", "plural_name": "untsi",
"description": "", "description": "",
"abbreviation": "oz" "abbreviation": "oz"
}, },
"gram": { "gram": {
"name": "gram", "name": "gramm",
"plural_name": "grams", "plural_name": "grammi",
"description": "", "description": "",
"abbreviation": "g" "abbreviation": "g"
}, },
"kilogram": { "kilogram": {
"name": "kilogram", "name": "kilogramm",
"plural_name": "kilograms", "plural_name": "kilogrammi",
"description": "", "description": "",
"abbreviation": "kg" "abbreviation": "kg"
}, },
"milligram": { "milligram": {
"name": "milligram", "name": "milligramm",
"plural_name": "milligrams", "plural_name": "milligrammi",
"description": "", "description": "",
"abbreviation": "mg" "abbreviation": "mg"
}, },
"splash": { "splash": {
"name": "splash", "name": "tilk",
"plural_name": "splashes", "plural_name": "tilkka",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"dash": { "dash": {
"name": "dash", "name": "näpuotsatäis",
"plural_name": "dashes", "plural_name": "näpuotsatäit",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"serving": { "serving": {
"name": "serving", "name": "portsjon",
"plural_name": "servings", "plural_name": "portsjonit",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"head": { "head": {
"name": "head", "name": "pea",
"plural_name": "heads", "plural_name": "pead",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"clove": { "clove": {
"name": "clove", "name": "küüs",
"plural_name": "cloves", "plural_name": "küünt",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"can": { "can": {
"name": "can", "name": "purk",
"plural_name": "cans", "plural_name": "purki",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"bunch": { "bunch": {
"name": "bunch", "name": "kimp",
"plural_name": "bunches", "plural_name": "kimpu",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"pack": { "pack": {
"name": "pack", "name": "pakk",
"plural_name": "packs", "plural_name": "pakki",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
"pinch": { "pinch": {
"name": "pinch", "name": "näpuotsatäis",
"plural_name": "pinches", "plural_name": "näpuotsatäit",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
} }

View file

@ -5,7 +5,7 @@ from fastapi import Depends, HTTPException, status
from mealie.routes._base.base_controllers import BaseUserController from mealie.routes._base.base_controllers import BaseUserController
from mealie.routes._base.controller import controller from mealie.routes._base.controller import controller
from mealie.routes._base.routers import UserAPIRouter from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.household.household import HouseholdInDB from mealie.schema.household import HouseholdInDB, HouseholdRecipeSummary
from mealie.schema.household.household_permissions import SetPermissions from mealie.schema.household.household_permissions import SetPermissions
from mealie.schema.household.household_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences from mealie.schema.household.household_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences
from mealie.schema.household.household_statistics import HouseholdStatistics from mealie.schema.household.household_statistics import HouseholdStatistics
@ -27,6 +27,15 @@ class HouseholdSelfServiceController(BaseUserController):
"""Returns the Household Data for the Current User""" """Returns the Household Data for the Current User"""
return self.household return self.household
@router.get("/self/recipes/{recipe_slug}", response_model=HouseholdRecipeSummary)
def get_household_recipe(self, recipe_slug: str):
"""Returns recipe data for the current household"""
response = self.service.get_household_recipe(recipe_slug)
if not response:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return response
@router.get("/members", response_model=PaginationBase[UserOut]) @router.get("/members", response_model=PaginationBase[UserOut])
def get_household_members(self, q: PaginationQuery = Depends()): def get_household_members(self, q: PaginationQuery = Depends()):
"""Returns all users belonging to the current household""" """Returns all users belonging to the current household"""
@ -67,6 +76,9 @@ class HouseholdSelfServiceController(BaseUserController):
if target_user.household_id != self.household_id: if target_user.household_id != self.household_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not a member of this household") raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not a member of this household")
if target_user.id == self.user.id:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not allowed to change their own permissions")
target_user.can_invite = permissions.can_invite target_user.can_invite = permissions.can_invite
target_user.can_manage = permissions.can_manage target_user.can_manage = permissions.can_manage
target_user.can_manage_household = permissions.can_manage_household target_user.can_manage_household = permissions.can_manage_household

View file

@ -53,7 +53,9 @@ class GroupMealplanController(BaseCrudController):
""" """
rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(plan_date), entry_type.value) rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(plan_date), entry_type.value)
cross_household_recipes = get_repositories(self.session, group_id=self.group_id, household_id=None).recipes cross_household_recipes = get_repositories(
self.session, group_id=self.group_id, household_id=None
).recipes.by_user(self.user.id)
qf_string = " AND ".join([f"({rule.query_filter_string})" for rule in rules if rule.query_filter_string]) qf_string = " AND ".join([f"({rule.query_filter_string})" for rule in rules if rule.query_filter_string])
recipes_data = cross_household_recipes.page_all( recipes_data = cross_household_recipes.page_all(

View file

@ -46,6 +46,7 @@ class RecipeToolController(BaseUserController):
@router.put("/{item_id}", response_model=RecipeTool) @router.put("/{item_id}", response_model=RecipeTool)
def update_one(self, item_id: UUID4, data: RecipeToolCreate): def update_one(self, item_id: UUID4, data: RecipeToolCreate):
data = mapper.cast(data, RecipeToolSave, group_id=self.group_id)
return self.mixins.update_one(data, item_id) return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=RecipeTool) @router.delete("/{item_id}", response_model=RecipeTool)

View file

@ -24,6 +24,7 @@ from mealie.core.dependencies import (
get_temporary_zip_path, get_temporary_zip_path,
) )
from mealie.pkgs import cache from mealie.pkgs import cache
from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import controller from mealie.routes._base import controller
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.cookbook.cookbook import ReadCookBook
@ -252,8 +253,9 @@ class RecipeController(BaseRecipeController):
if cookbook_data is None: if cookbook_data is None:
raise HTTPException(status_code=404, detail="cookbook not found") raise HTTPException(status_code=404, detail="cookbook not found")
# We use "group_recipes" here so we can return all recipes regardless of household. The query filter can include # We use "group_recipes" here so we can return all recipes regardless of household. The query filter can
# a household_id to filter by household. We use the "by_user" so we can sort favorites correctly. # include a household_id to filter by household.
# We use "by_user" so we can sort favorites and other user-specific data correctly.
pagination_response = self.group_recipes.by_user(self.user.id).page_all( pagination_response = self.group_recipes.by_user(self.user.id).page_all(
pagination=q, pagination=q,
cookbook=cookbook_data, cookbook=cookbook_data,
@ -288,7 +290,11 @@ class RecipeController(BaseRecipeController):
foods: list[UUID4] | None = Query(None), foods: list[UUID4] | None = Query(None),
tools: list[UUID4] | None = Query(None), tools: list[UUID4] | None = Query(None),
) -> RecipeSuggestionResponse: ) -> RecipeSuggestionResponse:
recipes = self.group_recipes.find_suggested_recipes(q, foods, tools) group_recipes_by_user = get_repositories(
self.session, group_id=self.group_id, household_id=None
).recipes.by_user(self.user.id)
recipes = group_recipes_by_user.find_suggested_recipes(q, foods, tools)
response = RecipeSuggestionResponse(items=recipes) response = RecipeSuggestionResponse(items=recipes)
json_compatible_response = orjson.dumps(response.model_dump(by_alias=True)) json_compatible_response = orjson.dumps(response.model_dump(by_alias=True))

View file

@ -1,5 +1,6 @@
from functools import cached_property from functools import cached_property
from fastapi import HTTPException
from pydantic import UUID4 from pydantic import UUID4
from mealie.routes._base import BaseUserController, controller from mealie.routes._base import BaseUserController, controller
@ -30,6 +31,11 @@ class RecipeSharedController(BaseUserController):
@router.post("", response_model=RecipeShareToken, status_code=201) @router.post("", response_model=RecipeShareToken, status_code=201)
def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken: def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken:
# check if recipe group id is the same as the user group id
recipe = self.repos.recipes.get_one(data.recipe_id, "id")
if recipe is None or recipe.group_id != self.group_id:
raise HTTPException(status_code=404, detail="Recipe not found in your group")
save_data = RecipeShareTokenSave(**data.model_dump(), group_id=self.group_id) save_data = RecipeShareTokenSave(**data.model_dump(), group_id=self.group_id)
return self.mixins.create_one(save_data) return self.mixins.create_one(save_data)

View file

@ -66,6 +66,7 @@ class IngredientFoodsController(BaseUserController):
@router.put("/{item_id}", response_model=IngredientFood) @router.put("/{item_id}", response_model=IngredientFood)
def update_one(self, item_id: UUID4, data: CreateIngredientFood): def update_one(self, item_id: UUID4, data: CreateIngredientFood):
data = mapper.cast(data, SaveIngredientFood, group_id=self.group_id)
return self.mixins.update_one(data, item_id) return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=IngredientFood) @router.delete("/{item_id}", response_model=IngredientFood)

View file

@ -1,10 +1,49 @@
from fastapi import HTTPException, status from fastapi import HTTPException, status
from pydantic import UUID4 from pydantic import UUID4
from mealie.schema.user.user import PrivateUser from mealie.schema.response.responses import ErrorResponse
from mealie.schema.user.user import PrivateUser, UserBase
permission_attrs = ["can_invite", "can_manage", "can_manage_household", "can_organize", "admin"]
def assert_user_change_allowed(id: UUID4, current_user: PrivateUser): def _assert_non_admin_user_change_allowed(user_id: UUID4, current_user: PrivateUser, new_data: UserBase):
if current_user.id != id and not current_user.admin: if current_user.id != user_id:
# only admins can edit other users # User is trying to edit another user
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="NOT_AN_ADMIN") raise HTTPException(status.HTTP_403_FORBIDDEN, ErrorResponse.respond("User cannot edit other users"))
if any(getattr(current_user, p) != getattr(new_data, p) for p in permission_attrs):
# User is trying to change their own permissions
raise HTTPException(
status.HTTP_403_FORBIDDEN,
ErrorResponse.respond("User cannot change their own permissions"),
)
if current_user.group != new_data.group:
# prevent a regular user from changing their group
raise HTTPException(
status.HTTP_403_FORBIDDEN, ErrorResponse.respond("User doesn't have permission to change their group")
)
if current_user.household != new_data.household:
# prevent a regular user from changing their household
raise HTTPException(
status.HTTP_403_FORBIDDEN,
ErrorResponse.respond("User doesn't have permission to change their household"),
)
def assert_user_change_allowed(user_id: UUID4, current_user: PrivateUser, new_data: UserBase):
if not current_user.admin:
_assert_non_admin_user_change_allowed(user_id, current_user, new_data)
return
if current_user.id != user_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, ErrorResponse.respond("Use the Admin API to update other users"))
# Admin is trying to edit themselves
if any(getattr(current_user, p) != getattr(new_data, p) for p in permission_attrs):
# prevent an admin from excalating their own permissions
raise HTTPException(
status.HTTP_403_FORBIDDEN, ErrorResponse.respond("Admins can't change their own permissions")
)

View file

@ -46,13 +46,6 @@ class AdminUserController(BaseAdminController):
@admin_router.delete("/{item_id}") @admin_router.delete("/{item_id}")
def delete_user(self, item_id: UUID4): def delete_user(self, item_id: UUID4):
"""Removes a user from the database. Must be the current user or a super user"""
assert_user_change_allowed(item_id, self.user)
if item_id == 1: # TODO: identify super_user
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPER_USER")
self.mixins.delete_one(item_id) self.mixins.delete_one(item_id)
@ -106,19 +99,7 @@ class UserController(BaseUserController):
@user_router.put("/{item_id}") @user_router.put("/{item_id}")
def update_user(self, item_id: UUID4, new_data: UserBase): def update_user(self, item_id: UUID4, new_data: UserBase):
assert_user_change_allowed(item_id, self.user) assert_user_change_allowed(item_id, self.user, new_data)
if not self.user.admin and (new_data.admin or self.user.group != new_data.group):
# prevent a regular user from doing admin tasks on themself
raise HTTPException(
status.HTTP_403_FORBIDDEN, ErrorResponse.respond("User doesn't have permission to change group")
)
if self.user.id == item_id and self.user.admin and not new_data.admin:
# prevent an admin from demoting themself
raise HTTPException(
status.HTTP_403_FORBIDDEN, ErrorResponse.respond("User doesn't have permission to change group")
)
try: try:
self.repos.users.update(item_id, new_data.model_dump()) self.repos.users.update(item_id, new_data.model_dump())

View file

@ -23,7 +23,7 @@ class UserImageController(BaseUserController):
): ):
"""Updates a User Image""" """Updates a User Image"""
with get_temporary_path() as temp_path: with get_temporary_path() as temp_path:
assert_user_change_allowed(id, self.user) assert_user_change_allowed(id, self.user, self.user)
temp_img = temp_path.joinpath(profile.filename) temp_img = temp_path.joinpath(profile.filename)
with temp_img.open("wb") as buffer: with temp_img.open("wb") as buffer:

View file

@ -54,7 +54,7 @@ class UserRatingsController(BaseUserController):
@router.post("/{id}/ratings/{slug}") @router.post("/{id}/ratings/{slug}")
def set_rating(self, id: UUID4, slug: str, data: UserRatingUpdate): def set_rating(self, id: UUID4, slug: str, data: UserRatingUpdate):
"""Sets the user's rating for a recipe""" """Sets the user's rating for a recipe"""
assert_user_change_allowed(id, self.user) assert_user_change_allowed(id, self.user, self.user)
recipe = self.get_recipe_or_404(slug) recipe = self.get_recipe_or_404(slug)
user_rating = self.repos.user_ratings.get_by_user_and_recipe(id, recipe.id) user_rating = self.repos.user_ratings.get_by_user_and_recipe(id, recipe.id)

View file

@ -46,6 +46,11 @@ from .household import (
HouseholdCreate, HouseholdCreate,
HouseholdInDB, HouseholdInDB,
HouseholdPagination, HouseholdPagination,
HouseholdRecipeBase,
HouseholdRecipeCreate,
HouseholdRecipeOut,
HouseholdRecipeSummary,
HouseholdRecipeUpdate,
HouseholdSave, HouseholdSave,
HouseholdSummary, HouseholdSummary,
HouseholdUserSummary, HouseholdUserSummary,
@ -91,6 +96,11 @@ __all__ = [
"HouseholdCreate", "HouseholdCreate",
"HouseholdInDB", "HouseholdInDB",
"HouseholdPagination", "HouseholdPagination",
"HouseholdRecipeBase",
"HouseholdRecipeCreate",
"HouseholdRecipeOut",
"HouseholdRecipeSummary",
"HouseholdRecipeUpdate",
"HouseholdSave", "HouseholdSave",
"HouseholdSummary", "HouseholdSummary",
"HouseholdUserSummary", "HouseholdUserSummary",

View file

@ -1,10 +1,11 @@
from datetime import datetime
from typing import Annotated from typing import Annotated
from pydantic import UUID4, ConfigDict, StringConstraints, field_validator from pydantic import UUID4, ConfigDict, StringConstraints, field_validator
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models.household.household import Household from mealie.db.models.household import Household, HouseholdToRecipe
from mealie.db.models.users.users import User from mealie.db.models.users.users import User
from mealie.schema._mealie.mealie_model import MealieModel from mealie.schema._mealie.mealie_model import MealieModel
from mealie.schema.household.webhook import ReadWebhook from mealie.schema.household.webhook import ReadWebhook
@ -13,6 +14,34 @@ from mealie.schema.response.pagination import PaginationBase
from .household_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences from .household_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences
class HouseholdRecipeBase(MealieModel):
last_made: datetime | None = None
class HouseholdRecipeSummary(HouseholdRecipeBase):
recipe_id: UUID4
model_config = ConfigDict(from_attributes=True)
class HouseholdRecipeCreate(HouseholdRecipeBase):
household_id: UUID4
recipe_id: UUID4
class HouseholdRecipeUpdate(HouseholdRecipeBase): ...
class HouseholdRecipeOut(HouseholdRecipeCreate):
id: UUID4
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
joinedload(HouseholdToRecipe.household),
]
class HouseholdCreate(MealieModel): class HouseholdCreate(MealieModel):
group_id: UUID4 | None = None group_id: UUID4 | None = None
name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]

View file

@ -59,7 +59,17 @@ class RecipeCategoryPagination(PaginationBase):
class RecipeTool(RecipeTag): class RecipeTool(RecipeTag):
id: UUID4 id: UUID4
on_hand: bool = False households_with_tool: list[str] = []
@field_validator("households_with_tool", mode="before")
def convert_households_to_slugs(cls, v):
if not v:
return []
try:
return [household.slug for household in v]
except AttributeError:
return v
class RecipeToolPagination(PaginationBase): class RecipeToolPagination(PaginationBase):

View file

@ -15,6 +15,7 @@ class UserBase(MealieModel):
id: UUID4 id: UUID4
username: str | None = None username: str | None = None
admin: bool admin: bool
full_name: str | None = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View file

@ -7,7 +7,7 @@ from typing import ClassVar
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import UUID4, ConfigDict, Field, field_validator, model_validator from pydantic import UUID4, ConfigDict, Field, field_validator, model_validator
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models.recipe import IngredientFoodModel from mealie.db.models.recipe import IngredientFoodModel
@ -37,7 +37,6 @@ class UnitFoodBase(MealieModel):
plural_name: str | None = None plural_name: str | None = None
description: str = "" description: str = ""
extras: dict | None = {} extras: dict | None = {}
on_hand: bool = False
@field_validator("id", mode="before") @field_validator("id", mode="before")
def convert_empty_id_to_none(cls, v): def convert_empty_id_to_none(cls, v):
@ -67,6 +66,7 @@ class IngredientFoodAlias(CreateIngredientFoodAlias):
class CreateIngredientFood(UnitFoodBase): class CreateIngredientFood(UnitFoodBase):
label_id: UUID4 | None = None label_id: UUID4 | None = None
aliases: list[CreateIngredientFoodAlias] = [] aliases: list[CreateIngredientFoodAlias] = []
households_with_ingredient_food: list[str] = []
class SaveIngredientFood(CreateIngredientFood): class SaveIngredientFood(CreateIngredientFood):
@ -91,10 +91,24 @@ class IngredientFood(CreateIngredientFood):
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
return [ return [
selectinload(IngredientFoodModel.households_with_ingredient_food),
joinedload(IngredientFoodModel.extras), joinedload(IngredientFoodModel.extras),
joinedload(IngredientFoodModel.label), joinedload(IngredientFoodModel.label),
] ]
@field_validator("households_with_ingredient_food", mode="before")
def convert_households_to_slugs(cls, v):
if not v:
return []
try:
return [household.slug for household in v]
except AttributeError:
return v
def is_on_hand(self, household_slug: str) -> bool:
return household_slug in self.households_with_tool
class IngredientFoodPagination(PaginationBase): class IngredientFoodPagination(PaginationBase):
items: list[IngredientFood] items: list[IngredientFood]

View file

@ -1,15 +1,14 @@
from pydantic import UUID4, ConfigDict from pydantic import UUID4, ConfigDict, field_validator
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models.recipe import RecipeModel, Tool
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from ...db.models.recipe import RecipeModel, Tool
class RecipeToolCreate(MealieModel): class RecipeToolCreate(MealieModel):
name: str name: str
on_hand: bool = False households_with_tool: list[str] = []
class RecipeToolSave(RecipeToolCreate): class RecipeToolSave(RecipeToolCreate):
@ -19,8 +18,28 @@ class RecipeToolSave(RecipeToolCreate):
class RecipeToolOut(RecipeToolCreate): class RecipeToolOut(RecipeToolCreate):
id: UUID4 id: UUID4
slug: str slug: str
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@field_validator("households_with_tool", mode="before")
def convert_households_to_slugs(cls, v):
if not v:
return []
try:
return [household.slug for household in v]
except AttributeError:
return v
def is_on_hand(self, household_slug: str) -> bool:
return household_slug in self.households_with_tool
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
selectinload(Tool.households_with_tool),
]
class RecipeToolResponse(RecipeToolOut): class RecipeToolResponse(RecipeToolOut):
recipes: list["RecipeSummary"] = [] recipes: list["RecipeSummary"] = []
@ -29,6 +48,7 @@ class RecipeToolResponse(RecipeToolOut):
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
return [ return [
selectinload(Tool.households_with_tool),
selectinload(Tool.recipes).joinedload(RecipeModel.recipe_category), selectinload(Tool.recipes).joinedload(RecipeModel.recipe_category),
selectinload(Tool.recipes).joinedload(RecipeModel.tags), selectinload(Tool.recipes).joinedload(RecipeModel.tags),
selectinload(Tool.recipes).joinedload(RecipeModel.tools), selectinload(Tool.recipes).joinedload(RecipeModel.tools),

View file

@ -6,10 +6,10 @@ from enum import Enum
from typing import Any, TypeVar, cast from typing import Any, TypeVar, cast
from uuid import UUID from uuid import UUID
import sqlalchemy as sa
from dateutil import parser as date_parser from dateutil import parser as date_parser
from dateutil.parser import ParserError from dateutil.parser import ParserError
from humps import decamelize from humps import decamelize
from sqlalchemy import ColumnElement, Select, and_, inspect, or_
from sqlalchemy.ext.associationproxy import AssociationProxyInstance from sqlalchemy.ext.associationproxy import AssociationProxyInstance
from sqlalchemy.orm import InstrumentedAttribute, Mapper from sqlalchemy.orm import InstrumentedAttribute, Mapper
from sqlalchemy.sql import sqltypes from sqlalchemy.sql import sqltypes
@ -251,17 +251,19 @@ class QueryFilterBuilder:
return f"<<{joined}>>" return f"<<{joined}>>"
@classmethod @classmethod
def _consolidate_group(cls, group: list[ColumnElement], logical_operators: deque[LogicalOperator]) -> ColumnElement: def _consolidate_group(
consolidated_group_builder: ColumnElement | None = None cls, group: list[sa.ColumnElement], logical_operators: deque[LogicalOperator]
) -> sa.ColumnElement:
consolidated_group_builder: sa.ColumnElement | None = None
for i, element in enumerate(reversed(group)): for i, element in enumerate(reversed(group)):
if not i: if not i:
consolidated_group_builder = element consolidated_group_builder = element
else: else:
operator = logical_operators.pop() operator = logical_operators.pop()
if operator is LogicalOperator.AND: if operator is LogicalOperator.AND:
consolidated_group_builder = and_(consolidated_group_builder, element) consolidated_group_builder = sa.and_(consolidated_group_builder, element)
elif operator is LogicalOperator.OR: elif operator is LogicalOperator.OR:
consolidated_group_builder = or_(consolidated_group_builder, element) consolidated_group_builder = sa.or_(consolidated_group_builder, element)
else: else:
raise ValueError(f"invalid logical operator {operator}") raise ValueError(f"invalid logical operator {operator}")
@ -270,8 +272,8 @@ class QueryFilterBuilder:
@classmethod @classmethod
def get_model_and_model_attr_from_attr_string( def get_model_and_model_attr_from_attr_string(
cls, attr_string: str, model: type[Model], *, query: Select | None = None cls, attr_string: str, model: type[Model], *, query: sa.Select | None = None
) -> tuple[SqlAlchemyBase, InstrumentedAttribute, Select | None]: ) -> tuple[SqlAlchemyBase, InstrumentedAttribute, sa.Select | None]:
""" """
Take an attribute string and traverse a database model and its relationships to get the desired Take an attribute string and traverse a database model and its relationships to get the desired
model and model attribute. Optionally provide a query to apply the necessary table joins. model and model attribute. Optionally provide a query to apply the necessary table joins.
@ -287,7 +289,7 @@ class QueryFilterBuilder:
mapper: Mapper mapper: Mapper
model_attr: InstrumentedAttribute | None = None model_attr: InstrumentedAttribute | None = None
attribute_chain = attr_string.split(".") attribute_chain = decamelize(attr_string).split(".")
if not attribute_chain: if not attribute_chain:
raise ValueError("invalid query string: attribute name cannot be empty") raise ValueError("invalid query string: attribute name cannot be empty")
@ -306,7 +308,7 @@ class QueryFilterBuilder:
if query is not None: if query is not None:
query = query.join(model_attr, isouter=True) query = query.join(model_attr, isouter=True)
mapper = inspect(current_model) mapper = sa.inspect(current_model)
relationship = mapper.relationships[proxied_attribute_link] relationship = mapper.relationships[proxied_attribute_link]
current_model = relationship.mapper.class_ current_model = relationship.mapper.class_
model_attr = getattr(current_model, next_attribute_link) model_attr = getattr(current_model, next_attribute_link)
@ -318,7 +320,7 @@ class QueryFilterBuilder:
if query is not None: if query is not None:
query = query.join(model_attr, isouter=True) query = query.join(model_attr, isouter=True)
mapper = inspect(current_model) mapper = sa.inspect(current_model)
relationship = mapper.relationships[attribute_link] relationship = mapper.relationships[attribute_link]
current_model = relationship.mapper.class_ current_model = relationship.mapper.class_
@ -330,7 +332,56 @@ class QueryFilterBuilder:
return current_model, model_attr, query return current_model, model_attr, query
def filter_query(self, query: Select, model: type[Model]) -> Select: @staticmethod
def _get_filter_element(
component: QueryFilterBuilderComponent, model, model_attr, model_attr_type
) -> sa.ColumnElement:
# Keywords
if component.relationship is RelationalKeyword.IS:
element = model_attr.is_(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.IS_NOT:
element = model_attr.is_not(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.IN:
element = model_attr.in_(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.NOT_IN:
element = model_attr.not_in(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.CONTAINS_ALL:
primary_model_attr: InstrumentedAttribute = getattr(model, component.attribute_name.split(".")[0])
element = sa.and_()
for v in component.validate(model_attr_type):
element = sa.and_(element, primary_model_attr.any(model_attr == v))
elif component.relationship is RelationalKeyword.LIKE:
element = model_attr.like(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.NOT_LIKE:
element = model_attr.not_like(component.validate(model_attr_type))
# Operators
elif component.relationship is RelationalOperator.EQ:
element = model_attr == component.validate(model_attr_type)
elif component.relationship is RelationalOperator.NOTEQ:
element = model_attr != component.validate(model_attr_type)
elif component.relationship is RelationalOperator.GT:
element = model_attr > component.validate(model_attr_type)
elif component.relationship is RelationalOperator.LT:
element = model_attr < component.validate(model_attr_type)
elif component.relationship is RelationalOperator.GTE:
element = model_attr >= component.validate(model_attr_type)
elif component.relationship is RelationalOperator.LTE:
element = model_attr <= component.validate(model_attr_type)
else:
raise ValueError(f"invalid relationship {component.relationship}")
return element
def filter_query(
self, query: sa.Select, model: type[Model], column_aliases: dict[str, sa.ColumnElement] | None = None
) -> sa.Select:
"""
Filters a query based on the parsed filter string.
If you need to filter on a custom column expression (e.g. a computed property), you can supply column aliases
"""
column_aliases = column_aliases or {}
# join tables and build model chain # join tables and build model chain
attr_model_map: dict[int, Any] = {} attr_model_map: dict[int, Any] = {}
model_attr: InstrumentedAttribute model_attr: InstrumentedAttribute
@ -344,8 +395,8 @@ class QueryFilterBuilder:
attr_model_map[i] = nested_model attr_model_map[i] = nested_model
# build query filter # build query filter
partial_group: list[ColumnElement] = [] partial_group: list[sa.ColumnElement] = []
partial_group_stack: deque[list[ColumnElement]] = deque() partial_group_stack: deque[list[sa.ColumnElement]] = deque()
logical_operator_stack: deque[LogicalOperator] = deque() logical_operator_stack: deque[LogicalOperator] = deque()
for i, component in enumerate(self.filter_components): for i, component in enumerate(self.filter_components):
if component == self.l_group_sep: if component == self.l_group_sep:
@ -365,43 +416,13 @@ class QueryFilterBuilder:
else: else:
component = cast(QueryFilterBuilderComponent, component) component = cast(QueryFilterBuilderComponent, component)
model_attr = getattr(attr_model_map[i], component.attribute_name.split(".")[-1]) base_attribute_name = component.attribute_name.split(".")[-1]
model_attr = getattr(attr_model_map[i], base_attribute_name)
# Keywords if (column_alias := column_aliases.get(base_attribute_name)) is not None:
if component.relationship is RelationalKeyword.IS: model_attr = column_alias
element = model_attr.is_(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.IS_NOT:
element = model_attr.is_not(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.IN:
element = model_attr.in_(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.NOT_IN:
element = model_attr.not_in(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.CONTAINS_ALL:
primary_model_attr: InstrumentedAttribute = getattr(model, component.attribute_name.split(".")[0])
element = and_()
for v in component.validate(model_attr.type):
element = and_(element, primary_model_attr.any(model_attr == v))
elif component.relationship is RelationalKeyword.LIKE:
element = model_attr.like(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.NOT_LIKE:
element = model_attr.not_like(component.validate(model_attr.type))
# Operators
elif component.relationship is RelationalOperator.EQ:
element = model_attr == component.validate(model_attr.type)
elif component.relationship is RelationalOperator.NOTEQ:
element = model_attr != component.validate(model_attr.type)
elif component.relationship is RelationalOperator.GT:
element = model_attr > component.validate(model_attr.type)
elif component.relationship is RelationalOperator.LT:
element = model_attr < component.validate(model_attr.type)
elif component.relationship is RelationalOperator.GTE:
element = model_attr >= component.validate(model_attr.type)
elif component.relationship is RelationalOperator.LTE:
element = model_attr <= component.validate(model_attr.type)
else:
raise ValueError(f"invalid relationship {component.relationship}")
element = self._get_filter_element(component, model, model_attr, model_attr.type)
partial_group.append(element) partial_group.append(element)
# combine the completed groups into one filter # combine the completed groups into one filter

View file

@ -1,10 +1,15 @@
from uuid import UUID
from pydantic import UUID4 from pydantic import UUID4
from mealie.core import exceptions
from mealie.repos.all_repositories import get_repositories from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.schema.household.household import HouseholdCreate from mealie.schema.household import HouseholdCreate, HouseholdRecipeSummary
from mealie.schema.household.household import HouseholdRecipeCreate, HouseholdRecipeUpdate
from mealie.schema.household.household_preferences import CreateHouseholdPreferences, SaveHouseholdPreferences from mealie.schema.household.household_preferences import CreateHouseholdPreferences, SaveHouseholdPreferences
from mealie.schema.household.household_statistics import HouseholdStatistics from mealie.schema.household.household_statistics import HouseholdStatistics
from mealie.schema.recipe.recipe import Recipe
from mealie.services._base_service import BaseService from mealie.services._base_service import BaseService
@ -15,6 +20,19 @@ class HouseholdService(BaseService):
self.repos = repos self.repos = repos
super().__init__() super().__init__()
def _get_recipe(self, recipe_slug: str | UUID) -> Recipe | None:
key = "id"
if not isinstance(recipe_slug, UUID):
try:
UUID(recipe_slug)
except ValueError:
key = "slug"
cross_household_recipes = get_repositories(
self.repos.session, group_id=self.group_id, household_id=None
).recipes
return cross_household_recipes.get_one(recipe_slug, key)
@staticmethod @staticmethod
def create_household( def create_household(
repos: AllRepositories, h_base: HouseholdCreate, prefs: CreateHouseholdPreferences | None = None repos: AllRepositories, h_base: HouseholdCreate, prefs: CreateHouseholdPreferences | None = None
@ -48,3 +66,34 @@ class HouseholdService(BaseService):
household_id = household_id or self.household_id household_id = household_id or self.household_id
return self.repos.households.statistics(group_id, household_id) return self.repos.households.statistics(group_id, household_id)
def get_household_recipe(self, recipe_slug: str) -> HouseholdRecipeSummary | None:
"""Returns recipe data for the current household"""
recipe = self._get_recipe(recipe_slug)
if not recipe:
return None
household_recipe_out = self.repos.household_recipes.get_by_recipe(recipe.id)
if household_recipe_out:
return household_recipe_out.cast(HouseholdRecipeSummary)
else:
return HouseholdRecipeSummary(recipe_id=recipe.id)
def set_household_recipe(self, recipe_slug: str | UUID, data: HouseholdRecipeUpdate) -> HouseholdRecipeSummary:
"""Sets the household's recipe data"""
recipe = self._get_recipe(recipe_slug)
if not recipe:
raise exceptions.NoEntryFound("Recipe not found.")
existing_household_recipe = self.repos.household_recipes.get_by_recipe(recipe.id)
if existing_household_recipe:
updated_data = existing_household_recipe.cast(HouseholdRecipeUpdate, **data.model_dump())
household_recipe_out = self.repos.household_recipes.patch(existing_household_recipe.id, updated_data)
else:
create_data = HouseholdRecipeCreate(
household_id=self.household_id, recipe_id=recipe.id, **data.model_dump()
)
household_recipe_out = self.repos.household_recipes.create(create_data)
return household_recipe_out.cast(HouseholdRecipeSummary)

View file

@ -19,7 +19,7 @@ from mealie.pkgs import cache
from mealie.repos.all_repositories import get_repositories from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.repos.repository_generic import RepositoryGeneric from mealie.repos.repository_generic import RepositoryGeneric
from mealie.schema.household.household import HouseholdInDB from mealie.schema.household.household import HouseholdInDB, HouseholdRecipeUpdate
from mealie.schema.openai.recipe import OpenAIRecipe from mealie.schema.openai.recipe import OpenAIRecipe
from mealie.schema.recipe.recipe import CreateRecipe, Recipe from mealie.schema.recipe.recipe import CreateRecipe, Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
@ -30,6 +30,7 @@ from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreat
from mealie.schema.recipe.request_helpers import RecipeDuplicate from mealie.schema.recipe.request_helpers import RecipeDuplicate
from mealie.schema.user.user import PrivateUser, UserRatingCreate from mealie.schema.user.user import PrivateUser, UserRatingCreate
from mealie.services._base_service import BaseService from mealie.services._base_service import BaseService
from mealie.services.household_services.household_service import HouseholdService
from mealie.services.openai import OpenAIDataInjection, OpenAILocalImage, OpenAIService from mealie.services.openai import OpenAIDataInjection, OpenAILocalImage, OpenAIService
from mealie.services.recipe.recipe_data_service import RecipeDataService from mealie.services.recipe.recipe_data_service import RecipeDataService
from mealie.services.scraper import cleaner from mealie.services.scraper import cleaner
@ -173,6 +174,7 @@ class RecipeService(RecipeServiceBase):
data.settings = RecipeSettings() data.settings = RecipeSettings()
rating_input = data.rating rating_input = data.rating
data.last_made = None
new_recipe = self.repos.recipes.create(data) new_recipe = self.repos.recipes.create(data)
# convert rating into user rating # convert rating into user rating
@ -342,6 +344,7 @@ class RecipeService(RecipeServiceBase):
if old_recipe.recipe_ingredient is None if old_recipe.recipe_ingredient is None
else list(map(copy_recipe_ingredient, old_recipe.recipe_ingredient)) else list(map(copy_recipe_ingredient, old_recipe.recipe_ingredient))
) )
new_recipe.last_made = None
new_recipe = self._recipe_creation_factory(new_name, additional_attrs=new_recipe.model_dump()) new_recipe = self._recipe_creation_factory(new_name, additional_attrs=new_recipe.model_dump())
@ -413,8 +416,11 @@ class RecipeService(RecipeServiceBase):
def update_last_made(self, slug_or_id: str | UUID, timestamp: datetime) -> Recipe: def update_last_made(self, slug_or_id: str | UUID, timestamp: datetime) -> Recipe:
# we bypass the pre update check since any user can update a recipe's last made date, even if it's locked, # we bypass the pre update check since any user can update a recipe's last made date, even if it's locked,
# or if the user belongs to a different household # or if the user belongs to a different household
recipe = self.get_one(slug_or_id)
return self.group_recipes.patch(recipe.slug, {"last_made": timestamp}) household_service = HouseholdService(self.user.group_id, self.user.household_id, self.repos)
household_service.set_household_recipe(slug_or_id, HouseholdRecipeUpdate(last_made=timestamp))
return self.get_one(slug_or_id)
def delete_one(self, slug_or_id: str | UUID) -> Recipe: def delete_one(self, slug_or_id: str | UUID) -> Recipe:
recipe = self.get_one(slug_or_id) recipe = self.get_one(slug_or_id)

View file

@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from mealie.db.db_setup import session_context from mealie.db.db_setup import session_context
from mealie.repos.all_repositories import get_repositories from mealie.repos.all_repositories import get_repositories
from mealie.schema.household.household import HouseholdRecipeUpdate
from mealie.schema.meal_plan.new_meal import PlanEntryType from mealie.schema.meal_plan.new_meal import PlanEntryType
from mealie.schema.recipe.recipe import RecipeSummary from mealie.schema.recipe.recipe import RecipeSummary
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
@ -18,12 +19,14 @@ from mealie.services.event_bus_service.event_types import (
EventRecipeTimelineEventData, EventRecipeTimelineEventData,
EventTypes, EventTypes,
) )
from mealie.services.household_services.household_service import HouseholdService
def _create_mealplan_timeline_events_for_household( def _create_mealplan_timeline_events_for_household(
event_time: datetime, session: Session, group_id: UUID4, household_id: UUID4 event_time: datetime, session: Session, group_id: UUID4, household_id: UUID4
) -> None: ) -> None:
repos = get_repositories(session, group_id=group_id, household_id=household_id) repos = get_repositories(session, group_id=group_id, household_id=household_id)
household_service = HouseholdService(group_id, household_id, repos)
event_bus_service = EventBusService(session=session) event_bus_service = EventBusService(session=session)
timeline_events_to_create: list[RecipeTimelineEventCreate] = [] timeline_events_to_create: list[RecipeTimelineEventCreate] = []
@ -64,7 +67,8 @@ def _create_mealplan_timeline_events_for_household(
continue continue
# bump up the last made date # bump up the last made date
last_made = mealplan.recipe.last_made household_to_recipe = household_service.get_household_recipe(mealplan.recipe.slug)
last_made = household_to_recipe.last_made if household_to_recipe else None
if (not last_made or last_made.date() < event_time.date()) and mealplan.recipe_id not in recipes_to_update: if (not last_made or last_made.date() < event_time.date()) and mealplan.recipe_id not in recipes_to_update:
recipes_to_update[mealplan.recipe_id] = mealplan.recipe recipes_to_update[mealplan.recipe_id] = mealplan.recipe
@ -99,6 +103,7 @@ def _create_mealplan_timeline_events_for_household(
) )
for recipe in recipes_to_update.values(): for recipe in recipes_to_update.values():
household_service.set_household_recipe(recipe.slug, HouseholdRecipeUpdate(last_made=event_time))
repos.recipes.patch(recipe.slug, {"last_made": event_time}) repos.recipes.patch(recipe.slug, {"last_made": event_time})
event_bus_service.dispatch( event_bus_service.dispatch(
integration_id=DEFAULT_INTEGRATION_ID, integration_id=DEFAULT_INTEGRATION_ID,

View file

@ -129,10 +129,7 @@ def clean_image(image: str | list | dict | None = None, default: str = "no image
case [{"@id": str(_)}, *_]: case [{"@id": str(_)}, *_]:
return [x["@id"] for x in image if "@id" in x] return [x["@id"] for x in image if "@id" in x]
case _: case _:
logger.exception( logger.exception(f"Unexpected type for image: {type(image)}, {image}")
f"Unexpected type for image: {
type(image)}, {image}"
)
return [default] return [default]
@ -227,10 +224,7 @@ def clean_instructions(steps_object: list | dict | str, default: list | None = N
) )
) )
case _: case _:
raise TypeError( raise TypeError(f"Unexpected type for instructions: {type(steps_object)}, {steps_object}")
f"Unexpected type for instructions: {
type(steps_object)}, {steps_object}"
)
def _sanitize_instruction_text(line: str | dict) -> str: def _sanitize_instruction_text(line: str | dict) -> str:
@ -290,10 +284,7 @@ def clean_ingredients(ingredients: list | str | None, default: list | None = Non
case str(ingredients): case str(ingredients):
return [clean_string(ingredient) for ingredient in ingredients.splitlines() if ingredient.strip()] return [clean_string(ingredient) for ingredient in ingredients.splitlines() if ingredient.strip()]
case _: case _:
raise TypeError( raise TypeError(f"Unexpected type for ingredients: {type(ingredients)}, {ingredients}")
f"Unexpected type for ingredients: {
type(ingredients)}, {ingredients}"
)
def clean_int(val: str | int | None, min: int | None = None, max: int | None = None): def clean_int(val: str | int | None, min: int | None = None, max: int | None = None):
@ -531,10 +522,7 @@ def clean_categories(category: str | list) -> list[str]:
# #
return [cat["name"] for cat in category if "name" in cat] return [cat["name"] for cat in category if "name" in cat]
case _: case _:
raise TypeError( raise TypeError(f"Unexpected type for category: {type(category)}, {category}")
f"Unexpected type for category: {
type(category)}, {category}"
)
def clean_tags(data: str | list[str]) -> list[str]: def clean_tags(data: str | list[str]) -> list[str]:

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