mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-06-21 00:10:19 -04:00
Merge branch 'mealie-next' into fix/plan-to-eat-import-6360
This commit is contained in:
@@ -58,9 +58,6 @@ load_secrets() {
|
|||||||
"OIDC_CONFIGURATION_URL"
|
"OIDC_CONFIGURATION_URL"
|
||||||
"OIDC_CLIENT_ID"
|
"OIDC_CLIENT_ID"
|
||||||
"OIDC_CLIENT_SECRET"
|
"OIDC_CLIENT_SECRET"
|
||||||
|
|
||||||
"OPENAI_BASE_URL"
|
|
||||||
"OPENAI_API_KEY"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# If any secrets are set, prefer them over base environment variables.
|
# If any secrets are set, prefer them over base environment variables.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
An easy way to add recipes to Mealie from an Apple device is via an Apple Shortcut. This is a short guide to install an configure a shortcut able to add recipes via a link or image(s).
|
An easy way to add recipes to Mealie from an Apple device is via an Apple Shortcut. This is a short guide to install an configure a shortcut able to add recipes via a link or image(s).
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
If adding via images make sure to enable [Mealie's OpenAI Integration](https://docs.mealie.io/documentation/getting-started/installation/open-ai/)
|
If adding via images make sure to enable [Mealie's AI Integration](https://docs.mealie.io/documentation/getting-started/installation/ai-providers)
|
||||||
|
|
||||||
## Javascript can only be run via Shortcuts on the Safari browser on MacOS and iOS. If you do not use Safari you may skip this section
|
## Javascript can only be run via Shortcuts on the Safari browser on MacOS and iOS. If you do not use Safari you may skip this section
|
||||||
Some sites have begun blocking AI scraping bots, inadvertently blocking the recipe scraping library Mealie uses as well. To circumvent this, the shortcut uses javascript to capture the raw html loaded in the browser and sends that to mealie when possible.
|
Some sites have begun blocking AI scraping bots, inadvertently blocking the recipe scraping library Mealie uses as well. To circumvent this, the shortcut uses javascript to capture the raw html loaded in the browser and sends that to mealie when possible.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
6. Click the Edit button/icon again
|
6. Click the Edit button/icon again
|
||||||
7. Scroll to the ingredients and you should see new fields for Amount, Unit, Food, and Note. The Note in particular will contain the original text of the Recipe.
|
7. Scroll to the ingredients and you should see new fields for Amount, Unit, Food, and Note. The Note in particular will contain the original text of the Recipe.
|
||||||
8. Click `Parse` and you will be taken to the ingredient parsing page.
|
8. Click `Parse` and you will be taken to the ingredient parsing page.
|
||||||
9. Choose your parser. The `Natural Language Parser` works very well, but you can also use the `Brute Parser`, or the `OpenAI Parser` if you've [enabled OpenAI support](./installation/backend-config.md#openai).
|
9. Choose your parser. The `Natural Language Parser` works very well, but you can also use the `Brute Parser`, or the `OpenAI Parser` if you've [enabled AI support](./installation/ai-providers.md).
|
||||||
10. Click `Parse All`, and your ingredients should be separated out into Units and Foods based on your seeding in Step 1 above.
|
10. Click `Parse All`, and your ingredients should be separated out into Units and Foods based on your seeding in Step 1 above.
|
||||||
11. For ingredients where the Unit or Food was not found, you can click a button to accept an automatically suggested Food to add to the database. Or, manually enter the Unit/Food and hit `Enter` (or click `Create`) to add it to the database
|
11. For ingredients where the Unit or Food was not found, you can click a button to accept an automatically suggested Food to add to the database. Or, manually enter the Unit/Food and hit `Enter` (or click `Create`) to add it to the database
|
||||||
12. When done, click `Save All` and you will be taken back to the recipe. Now the Unit and Food fields of the recipe should be filled out.
|
12. When done, click `Save All` and you will be taken back to the recipe. Now the Unit and Food fields of the recipe should be filled out.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Mealie offers several ways to create recipes:
|
|||||||
- **Recipe HTML or JSON:** Copy/paste structured HTML or JSON and Mealie can import it.
|
- **Recipe HTML or JSON:** Copy/paste structured HTML or JSON and Mealie can import it.
|
||||||
- **Manual Editor:** Create recipes from scratch using the integrated editor.
|
- **Manual Editor:** Create recipes from scratch using the integrated editor.
|
||||||
|
|
||||||
Mealie's [AI integration](./installation/open-ai.md) greatly expands the ways you can create recipes:
|
Mealie's [AI integration](./installation/ai-providers.md) greatly expands the ways you can create recipes:
|
||||||
|
|
||||||
- **Image Import:** Upload an image of a written or typed recipe and Mealie will use OCR and AI to import it.
|
- **Image Import:** Upload an image of a written or typed recipe and Mealie will use OCR and AI to import it.
|
||||||
- **Video URL Import:** Provide a video URL (e.g., YouTube) and Mealie will transcribe the audio and turn it into a recipe.
|
- **Video URL Import:** Provide a video URL (e.g., YouTube) and Mealie will transcribe the audio and turn it into a recipe.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# AI Integration
|
||||||
|
|
||||||
|
:octicons-tag-24: v1.7.0
|
||||||
|
|
||||||
|
Mealie's AI integration enables several features and enhancements throughout the application. To enable AI features, you must have access to an AI provider (such as OpenAI). Mealie works with any OpenAI-compatible API.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
To set up AI providers, visit your group settings.
|
||||||
|
|
||||||
|
[Group Settings Demo](https://demo.mealie.io/group){ .md-button .md-button--primary }
|
||||||
|
|
||||||
|
- To enable AI features at all, you *must* set a default provider (e.g. `gpt-5`)
|
||||||
|
- To enable image recognition features, such as creating a recipe from an image, configure a provider capable of image recognition (e.g. `gpt-5`)
|
||||||
|
- To enable audio transcription features, such as importing a recipe from a video, configure a provider capable of audio transcriptions (e.g. `whisper-1`)
|
||||||
|
|
||||||
|
For most users, choosing an OpenAI model (such as `gpt-5`) and supplying the OpenAI API key is all you need to do. Note that while OpenAI has a free tier, it's not sufficiently capable for Mealie (or most other production use cases). For more information, check out [OpenAI's rate limits](https://platform.openai.com/docs/guides/rate-limits). If you deposit $5 into your OpenAI account, you will be permanently bumped up to Tier 1, which is sufficient for Mealie. Cost per-request is dependant on many factors, but Mealie tries to keep token counts conservative.
|
||||||
|
|
||||||
|
If you have another provider you'd like to use, such as Azure, you can configure Mealie to use that instead as long as it has an OpenAI-compatible API. For instance, a common self-hosted alternative to OpenAI is [Ollama](https://ollama.com/). To use Ollama with Mealie, set your `base_url` to `http://localhost:11434/v1` (where `http://localhost:11434` is wherever you're hosting Ollama, and `/v1` enables the OpenAI-compatible endpoints). Note that you *must* provide an API key, even though it is ultimately ignored by Ollama.
|
||||||
|
|
||||||
|
Note that some models are capable of handling multiple features (e.g. `gpt-5` can handle both normal chat requests and image recognition requests). You may configure one provider for multiple provider features.
|
||||||
|
|
||||||
|
While Mealie has prompts for each AI task, you can override these with your own prompts if you'd like. For more information, check out the [backend configuration](./installation/ai-providers.md).
|
||||||
|
|
||||||
|
## AI Features
|
||||||
|
- The OpenAI Ingredient Parser can be used as an alternative to the NLP and Brute Force parsers. Simply choose the OpenAI parser while parsing ingredients (:octicons-tag-24: v1.7.0)
|
||||||
|
- When importing a recipe via URL, if the default recipe scraper is unable to read the recipe data from a webpage, the webpage contents will be parsed by OpenAI (:octicons-tag-24: v1.9.0)
|
||||||
|
- You can import an image of a written recipe, which is sent to OpenAI and imported into Mealie. The recipe can be hand-written or typed, as long as the text is in the picture. You can also optionally have OpenAI translate the recipe into your own language (:octicons-tag-24: v1.12.0)
|
||||||
|
- You can import a recipe via a video URL (e.g., a YouTube link). The video is transcribed AI, and the transcription is parsed into a recipe (:octicons-tag-24: v3.13.0)
|
||||||
@@ -120,22 +120,10 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
|
|||||||
|
|
||||||
:octicons-tag-24: v1.7.0
|
:octicons-tag-24: v1.7.0
|
||||||
|
|
||||||
Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./open-ai.md).
|
Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./ai-providers.md).
|
||||||
For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values as JSON encoded strings (e.g. `OPENAI_CUSTOM_PARAMS='{"k1": "v1", "k2": "v2"}'`)
|
|
||||||
|
|
||||||
| Variables | Default | Description |
|
| Variables | Default | Description |
|
||||||
|-------------------------------------------------------------------------|:-----------:|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|-------------------------------------------------------------------------|:-----------:|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| OPENAI_BASE_URL<super>[†][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
|
|
||||||
| OPENAI_API_KEY<super>[†][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features |
|
|
||||||
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
|
|
||||||
| OPENAI_AUDIO_MODEL <br/> :octicons-tag-24: v3.13.0 | whisper-1 | Which OpenAI model to use for audio transcriptions, if enabled. If you're not sure, leave this empty |
|
|
||||||
| OPENAI_CUSTOM_HEADERS <br/> :octicons-tag-24: v2.0.0 | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
|
||||||
| OPENAI_CUSTOM_PARAMS <br/> :octicons-tag-24: v2.0.0 | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
|
||||||
| OPENAI_ENABLE_IMAGE_SERVICES <br/> :octicons-tag-24: v1.12.0 | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
|
||||||
| OPENAI_ENABLE_TRANSCRIPTION_SERVICES <br/> :octicons-tag-24: v3.13.0 | True | Whether to enable OpenAI transcription services, such as creating recipes via video URL. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
|
||||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
|
||||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
|
||||||
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
|
||||||
| OPENAI_CUSTOM_PROMPT_DIR <br/> :octicons-tag-24: v3.10.0 | None. | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. |
|
| OPENAI_CUSTOM_PROMPT_DIR <br/> :octicons-tag-24: v3.10.0 | None. | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. |
|
||||||
|
|
||||||
### Theming
|
### Theming
|
||||||
@@ -315,7 +303,6 @@ at least these sensitive environment variables when working within shared enviro
|
|||||||
- `POSTGRES_PASSWORD`
|
- `POSTGRES_PASSWORD`
|
||||||
- `SMTP_PASSWORD`
|
- `SMTP_PASSWORD`
|
||||||
- `LDAP_QUERY_PASSWORD`
|
- `LDAP_QUERY_PASSWORD`
|
||||||
- `OPENAI_API_KEY`
|
|
||||||
|
|
||||||
[docker-secrets]: https://docs.docker.com/compose/use-secrets/
|
[docker-secrets]: https://docs.docker.com/compose/use-secrets/
|
||||||
[secrets]: #docker-secrets
|
[secrets]: #docker-secrets
|
||||||
|
|||||||
@@ -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:v3.17.0`
|
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.18.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
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# OpenAI Integration
|
|
||||||
|
|
||||||
:octicons-tag-24: v1.7.0
|
|
||||||
|
|
||||||
Mealie's OpenAI integration enables several features and enhancements throughout the application. To enable OpenAI features, you must have an account with OpenAI and configure Mealie to use the OpenAI API key (for more information, check out the [backend configuration](./backend-config.md#openai)).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
For most users, supplying the OpenAI API key is all you need to do; you will use the regular OpenAI service with the default language model. Note that while OpenAI has a free tier, it's not sufficiently capable for Mealie (or most other production use cases). For more information, check out [OpenAI's rate limits](https://platform.openai.com/docs/guides/rate-limits). If you deposit $5 into your OpenAI account, you will be permanently bumped up to Tier 1, which is sufficient for Mealie. Cost per-request is dependant on many factors, but Mealie tries to keep token counts conservative.
|
|
||||||
|
|
||||||
Alternatively, if you have another service you'd like to use in-place of OpenAI, you can configure Mealie to use that instead, as long as it has an OpenAI-compatible API. For instance, a common self-hosted alternative to OpenAI is [Ollama](https://ollama.com/). To use Ollama with Mealie, change your `OPENAI_BASE_URL` to `http://localhost:11434/v1` (where `http://localhost:11434` is wherever you're hosting Ollama, and `/v1` enables the OpenAI-compatible endpoints). Note that you *must* provide an API key, even though it is ultimately ignored by Ollama.
|
|
||||||
|
|
||||||
If you wish to disable image recognition features (to save costs, or because your custom model doesn't support them) you can set `OPENAI_ENABLE_IMAGE_SERVICES` to `False`.
|
|
||||||
If you wish to disable transcription features (to save costs, or because your custom model doesn't support them) you can set `OPENAI_ENABLE_TRANSCRIPTION_SERVICES` to `False`.
|
|
||||||
|
|
||||||
For more information on what configuration options are available, check out the [backend configuration](./backend-config.md#openai).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## OpenAI Features
|
|
||||||
- The OpenAI Ingredient Parser can be used as an alternative to the NLP and Brute Force parsers. Simply choose the OpenAI parser while parsing ingredients (:octicons-tag-24: v1.7.0)
|
|
||||||
- When importing a recipe via URL, if the default recipe scraper is unable to read the recipe data from a webpage, the webpage contents will be parsed by OpenAI (:octicons-tag-24: v1.9.0)
|
|
||||||
- You can import an image of a written recipe, which is sent to OpenAI and imported into Mealie. The recipe can be hand-written or typed, as long as the text is in the picture. You can also optionally have OpenAI translate the recipe into your own language (:octicons-tag-24: v1.12.0)
|
|
||||||
- You can import a recipe via a video URL (e.g., a YouTube link). The video is transcribed using OpenAI's Whisper model, and the transcription is parsed into a recipe (:octicons-tag-24: v3.13.0)
|
|
||||||
@@ -10,7 +10,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:v3.17.0 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v3.18.0 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -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:v3.17.0 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v3.18.0 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ nav:
|
|||||||
- Backend Configuration: "documentation/getting-started/installation/backend-config.md"
|
- Backend Configuration: "documentation/getting-started/installation/backend-config.md"
|
||||||
- Security: "documentation/getting-started/installation/security.md"
|
- Security: "documentation/getting-started/installation/security.md"
|
||||||
- Logs: "documentation/getting-started/installation/logs.md"
|
- Logs: "documentation/getting-started/installation/logs.md"
|
||||||
- OpenAI: "documentation/getting-started/installation/open-ai.md"
|
- AI Providers: "documentation/getting-started/installation/ai-providers.md"
|
||||||
- Usage:
|
- Usage:
|
||||||
- Backup and Restoring: "documentation/getting-started/usage/backups-and-restoring.md"
|
- Backup and Restoring: "documentation/getting-started/usage/backups-and-restoring.md"
|
||||||
- Permissions and Public Access: "documentation/getting-started/usage/permissions-and-public-access.md"
|
- Permissions and Public Access: "documentation/getting-started/usage/permissions-and-public-access.md"
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p>AI providers can now be configured directly in Mealie, without managing environment variables or secrets.</p>
|
||||||
|
<div class="mb-2">
|
||||||
|
AI providers enable features such as:
|
||||||
|
<ul class="ml-6">
|
||||||
|
<li>Creating recipes from images</li>
|
||||||
|
<li>Importing recipes from videos (YouTube, TikTok, etc.)</li>
|
||||||
|
<li>Enhanced ingredient parsing</li>
|
||||||
|
<li>And more!</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<hr class="mt-2 mb-4">
|
||||||
|
<p>
|
||||||
|
<span v-if="group?.aiProviderSettings?.aiEnabled">
|
||||||
|
Your group already has AI providers configured.
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
Your group does not currently have any AI providers configured.
|
||||||
|
</span>
|
||||||
|
<span v-if="user?.canManage">
|
||||||
|
You can manage them here:
|
||||||
|
<br>
|
||||||
|
<v-btn class="mt-2" color="primary" to="/group">
|
||||||
|
{{ $t("profile.group-settings") }}
|
||||||
|
</v-btn>
|
||||||
|
</span>
|
||||||
|
<span v-else-if="!group?.aiProviderSettings?.aiEnabled">
|
||||||
|
Contact a group manager or server admin to set up AI providers for your group.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div v-if="user?.admin">
|
||||||
|
<br>
|
||||||
|
<p>
|
||||||
|
As an admin, you can configure AI providers for any group. Unlike the old environment variable approach, providers are configured per-group:
|
||||||
|
<br>
|
||||||
|
<v-btn class="mt-2" color="primary" to="/admin/manage/groups">
|
||||||
|
{{ $t("group.admin-group-management") }}
|
||||||
|
</v-btn>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useGroupSelf } from "~/composables/use-groups";
|
||||||
|
import type { AnnouncementMeta } from "~/composables/use-announcements";
|
||||||
|
|
||||||
|
const { user } = useMealieAuth();
|
||||||
|
const { group } = useGroupSelf();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export const meta: AnnouncementMeta = {
|
||||||
|
title: "Improved AI Provider Configuration",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="css">
|
||||||
|
p {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
204
frontend/app/components/Domain/Group/GroupAIProviderDialog.vue
Normal file
204
frontend/app/components/Domain/Group/GroupAIProviderDialog.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<BaseDialog
|
||||||
|
v-model="dialog"
|
||||||
|
:title="isEdit ? $t('group.ai-provider-settings.edit-provider') : $t('group.ai-provider-settings.create-provider')"
|
||||||
|
:icon="$globals.icons.robot"
|
||||||
|
:loading="loading"
|
||||||
|
can-submit
|
||||||
|
:submit-icon="isEdit ? $globals.icons.save : $globals.icons.createAlt"
|
||||||
|
:submit-text="isEdit ? $t('general.update') : $t('general.create')"
|
||||||
|
:submit-disabled="submitDisabled"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@close="resetForm"
|
||||||
|
>
|
||||||
|
<v-card-text v-if="init" style="max-height: 70vh; overflow-y: auto;">
|
||||||
|
<v-form ref="form" v-no-autofill>
|
||||||
|
<v-text-field
|
||||||
|
v-model="formData.name"
|
||||||
|
:label="$t('group.ai-provider-settings.provider-name')"
|
||||||
|
:rules="[validators.required]"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="formData.model"
|
||||||
|
:label="$t('group.ai-provider-settings.model')"
|
||||||
|
:hint="$t('group.ai-provider-settings.model-description')"
|
||||||
|
:rules="[validators.required]"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="formData.apiKey"
|
||||||
|
:label="$t('group.ai-provider-settings.api-key')"
|
||||||
|
:hint="$t(
|
||||||
|
isEdit
|
||||||
|
? 'group.ai-provider-settings.api-key-description-edit'
|
||||||
|
: 'group.ai-provider-settings.api-key-description-create',
|
||||||
|
)"
|
||||||
|
:persistent-hint="isEdit"
|
||||||
|
:rules="isEdit ? [] : [validators.required]"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
type="password"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="formData.baseUrl"
|
||||||
|
:label="$t('group.ai-provider-settings.base-url')"
|
||||||
|
:hint="$t('group.ai-provider-settings.base-url-description')"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<v-number-input
|
||||||
|
v-model.number="formData.timeout"
|
||||||
|
:label="$t('group.ai-provider-settings.request-timeout-seconds')"
|
||||||
|
type="number"
|
||||||
|
:min="0"
|
||||||
|
hide-details
|
||||||
|
control-variant="stacked"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<v-expansion-panels v-model="advancedPanel" variant="accordion">
|
||||||
|
<v-expansion-panel>
|
||||||
|
<v-expansion-panel-title class="text-subtitle-2" expand-icon="$expand" collapse-icon="$expand">
|
||||||
|
{{ $t('search.advanced') }}
|
||||||
|
</v-expansion-panel-title>
|
||||||
|
<v-expansion-panel-text class="px-0">
|
||||||
|
<div class="mb-2 text-subtitle-2">
|
||||||
|
{{ $t('group.ai-provider-settings.request-headers') }}
|
||||||
|
</div>
|
||||||
|
<BaseKeyValueEditor
|
||||||
|
v-model="formData.requestHeaders"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<v-divider class="mb-4" />
|
||||||
|
<div class="mb-2 text-subtitle-2">
|
||||||
|
{{ $t('group.ai-provider-settings.request-params') }}
|
||||||
|
</div>
|
||||||
|
<BaseKeyValueEditor
|
||||||
|
v-model="formData.requestParams"
|
||||||
|
/>
|
||||||
|
</v-expansion-panel-text>
|
||||||
|
</v-expansion-panel>
|
||||||
|
</v-expansion-panels>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
<AppLoader v-else waiting-text="" />
|
||||||
|
</BaseDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAIProviders } from "~/composables/use-ai-providers";
|
||||||
|
import { validators } from "~/composables/use-validators";
|
||||||
|
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
providerId?: string;
|
||||||
|
}>(), {
|
||||||
|
providerId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "create", data: AIProviderCreate): void;
|
||||||
|
(e: "update", id: string, data: AIProviderUpdate): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dialog = defineModel<boolean>({ default: false });
|
||||||
|
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
const { loading, getOne } = useAIProviders();
|
||||||
|
const init = ref(false);
|
||||||
|
|
||||||
|
const form = ref();
|
||||||
|
const advancedPanel = ref<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.providerId);
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
name: "",
|
||||||
|
model: "",
|
||||||
|
apiKey: "",
|
||||||
|
baseUrl: "",
|
||||||
|
timeout: 300,
|
||||||
|
requestHeaders: {} as Record<string, string>,
|
||||||
|
requestParams: {} as Record<string, string>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formData = reactive(defaultForm());
|
||||||
|
|
||||||
|
const submitDisabled = computed(() => {
|
||||||
|
return !formData.name?.trim() || !formData.model?.trim() || (!isEdit.value && !formData.apiKey?.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch existing provider when editing; reset form for create mode
|
||||||
|
watch(
|
||||||
|
() => [dialog.value, props.providerId] as const,
|
||||||
|
async ([open, id]) => {
|
||||||
|
if (!open) return;
|
||||||
|
if (!id) {
|
||||||
|
// Create mode — just show the empty form
|
||||||
|
resetForm();
|
||||||
|
init.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
init.value = false;
|
||||||
|
const { data } = await getOne(id);
|
||||||
|
init.value = true;
|
||||||
|
if (data) {
|
||||||
|
formData.name = data.name;
|
||||||
|
formData.model = data.model;
|
||||||
|
formData.apiKey = "";
|
||||||
|
formData.baseUrl = data.baseUrl ?? "";
|
||||||
|
formData.timeout = data.timeout ?? 300;
|
||||||
|
formData.requestHeaders = { ...(data.requestHeaders ?? {}) };
|
||||||
|
formData.requestParams = { ...(data.requestParams ?? {}) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
// Required field guard (button is also disabled, but keep as a safeguard)
|
||||||
|
if (!formData.name?.trim() || !formData.model?.trim()) return;
|
||||||
|
if (!isEdit.value && !formData.apiKey?.trim()) return;
|
||||||
|
|
||||||
|
if (isEdit.value && props.providerId) {
|
||||||
|
const payload: AIProviderUpdate & { apiKey?: string } = {
|
||||||
|
name: formData.name,
|
||||||
|
model: formData.model,
|
||||||
|
baseUrl: formData.baseUrl || null,
|
||||||
|
timeout: formData.timeout,
|
||||||
|
requestHeaders: Object.keys(formData.requestHeaders).length ? formData.requestHeaders : undefined,
|
||||||
|
requestParams: Object.keys(formData.requestParams).length ? formData.requestParams : undefined,
|
||||||
|
};
|
||||||
|
if (formData.apiKey) {
|
||||||
|
payload.apiKey = formData.apiKey;
|
||||||
|
}
|
||||||
|
emit("update", props.providerId, payload);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const createPayload = {
|
||||||
|
name: formData.name,
|
||||||
|
model: formData.model,
|
||||||
|
apiKey: formData.apiKey,
|
||||||
|
baseUrl: formData.baseUrl || null,
|
||||||
|
timeout: formData.timeout,
|
||||||
|
requestHeaders: Object.keys(formData.requestHeaders).length ? formData.requestHeaders : undefined,
|
||||||
|
requestParams: Object.keys(formData.requestParams).length ? formData.requestParams : undefined,
|
||||||
|
};
|
||||||
|
emit("create", createPayload as AIProviderCreate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
Object.assign(formData, defaultForm());
|
||||||
|
form.value?.reset();
|
||||||
|
advancedPanel.value = undefined;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="providerSettings">
|
||||||
|
<BaseCardSectionTitle v-if="!hideHeader" :title="$t('group.ai-provider-settings.ai-provider-settings')">
|
||||||
|
<template v-if="noDefaultProviderWarning" #append-title>
|
||||||
|
<v-tooltip location="bottom" color="warning">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<v-icon v-bind="tooltipProps" size="small" color="warning" class="ms-2">
|
||||||
|
{{ $globals.icons.alert }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
<span>{{ $t('group.ai-provider-settings.no-default-provider-warning') }}</span>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
</BaseCardSectionTitle>
|
||||||
|
<v-card-text v-if="!hideHeader" class="pt-0 pb-10 px-0">
|
||||||
|
{{ $t("group.ai-provider-settings.ai-provider-settings-description") }}
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-row class="mb-4">
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-autocomplete
|
||||||
|
v-model="local.defaultProviderId"
|
||||||
|
:label="$t('group.ai-provider-settings.default-provider')"
|
||||||
|
:items="local.providers"
|
||||||
|
item-title="name"
|
||||||
|
item-value="id"
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<v-card-subtitle class="mt-1">
|
||||||
|
{{ $t("group.ai-provider-settings.default-provider-description") }}
|
||||||
|
</v-card-subtitle>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-autocomplete
|
||||||
|
v-model="local.audioProviderId"
|
||||||
|
:label="$t('group.ai-provider-settings.audio-provider')"
|
||||||
|
:items="local.providers"
|
||||||
|
item-title="name"
|
||||||
|
item-value="id"
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<v-card-subtitle class="mt-1">
|
||||||
|
{{ $t("group.ai-provider-settings.audio-provider-description") }}
|
||||||
|
</v-card-subtitle>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-autocomplete
|
||||||
|
v-model="local.imageProviderId"
|
||||||
|
:label="$t('group.ai-provider-settings.image-provider')"
|
||||||
|
:items="local.providers"
|
||||||
|
item-title="name"
|
||||||
|
item-value="id"
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<v-card-subtitle class="mt-1">
|
||||||
|
{{ $t("group.ai-provider-settings.image-provider-description") }}
|
||||||
|
</v-card-subtitle>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<GroupAIProviderDialog
|
||||||
|
v-model="dialogOpen"
|
||||||
|
:provider-id="editingProviderId ?? undefined"
|
||||||
|
@create="(data) => $emit('create', data)"
|
||||||
|
@update="(id, data) => $emit('update', id, data)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BaseCardSectionTitle
|
||||||
|
:title="$t('group.ai-provider-settings.providers')"
|
||||||
|
size="medium"
|
||||||
|
class="pt-2"
|
||||||
|
>
|
||||||
|
<template #append-title>
|
||||||
|
<BaseButton
|
||||||
|
:text="$t('group.ai-provider-settings.create-provider')"
|
||||||
|
class="ms-auto my-2"
|
||||||
|
create
|
||||||
|
small
|
||||||
|
@click="openCreate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</BaseCardSectionTitle>
|
||||||
|
|
||||||
|
<v-card
|
||||||
|
v-for="provider in local.providers"
|
||||||
|
:key="provider.id"
|
||||||
|
variant="tonal"
|
||||||
|
class="pa-0 mb-4"
|
||||||
|
>
|
||||||
|
<v-row no-gutters>
|
||||||
|
<v-col :cols="10">
|
||||||
|
<v-card-text>
|
||||||
|
{{ provider.name }}
|
||||||
|
</v-card-text>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col :cols="2">
|
||||||
|
<BaseButtonGroup
|
||||||
|
:buttons="[
|
||||||
|
{
|
||||||
|
icon: $globals.icons.edit,
|
||||||
|
text: $t('general.edit'),
|
||||||
|
event: 'edit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.delete,
|
||||||
|
text: $t('general.delete'),
|
||||||
|
event: 'delete',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
@edit="openEdit(provider.id)"
|
||||||
|
@delete="$emit('delete', provider.id)"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
|
||||||
|
import type { AIProviderSettingsOut } from "~/lib/api/types/user";
|
||||||
|
|
||||||
|
const providerSettings = defineModel<AIProviderSettingsOut>({ required: true });
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
hideHeader?: boolean;
|
||||||
|
}>(), {
|
||||||
|
hideHeader: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { hideHeader } = toRefs(props);
|
||||||
|
|
||||||
|
const local = reactive({ ...providerSettings.value });
|
||||||
|
watch(local, (newVal) => { providerSettings.value = { ...newVal }; });
|
||||||
|
// Sync back when the parent refreshes after create/update/delete
|
||||||
|
watch(providerSettings, (newVal) => { if (newVal) Object.assign(local, newVal); });
|
||||||
|
|
||||||
|
const noDefaultProviderWarning = computed(
|
||||||
|
() => local.providers.length > 0 && !local.defaultProviderId,
|
||||||
|
);
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: "create", data: AIProviderCreate): void;
|
||||||
|
(e: "update", id: string, data: AIProviderUpdate): void;
|
||||||
|
(e: "delete", id: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dialogOpen = ref(false);
|
||||||
|
const editingProviderId = ref<string | null>(null);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingProviderId.value = null;
|
||||||
|
dialogOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(id: string) {
|
||||||
|
editingProviderId.value = id;
|
||||||
|
dialogOpen.value = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -200,6 +200,7 @@ import { useUserApi } from "~/composables/api";
|
|||||||
import { useIngredientTextParser } from "~/composables/recipes";
|
import { useIngredientTextParser } from "~/composables/recipes";
|
||||||
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
|
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
|
||||||
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
|
import { useGroupSelf } from "~/composables/use-groups";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
import { useParsingPreferences } from "~/composables/use-users/preferences";
|
import { useParsingPreferences } from "~/composables/use-users/preferences";
|
||||||
|
|
||||||
@@ -215,7 +216,7 @@ const emit = defineEmits<{
|
|||||||
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
|
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { $appInfo } = useNuxtApp();
|
const { group } = useGroupSelf();
|
||||||
const i18n = useGlobalI18n();
|
const i18n = useGlobalI18n();
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const drag = ref(false);
|
const drag = ref(false);
|
||||||
@@ -240,7 +241,7 @@ const availableParsers = computed(() => {
|
|||||||
{
|
{
|
||||||
text: i18n.t("recipe.parser.openai-parser"),
|
text: i18n.t("recipe.parser.openai-parser"),
|
||||||
value: "openai",
|
value: "openai",
|
||||||
hide: !$appInfo.enableOpenai,
|
hide: !group.value?.aiProviderSettings?.aiEnabled,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
style="border-color: lightgrey;"
|
style="border-color: lightgrey;"
|
||||||
:to="link.to"
|
:to="link.to"
|
||||||
height="100%"
|
height="100%"
|
||||||
class="d-flex flex-column mt-4"
|
class="d-flex flex-column mt-4 pa-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="$vuetify.display.smAndDown"
|
v-if="$vuetify.display.smAndDown"
|
||||||
|
|||||||
@@ -96,15 +96,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import type { SideBarLink } from "~/types/application-types";
|
import type { SideBarLink } from "~/types/application-types";
|
||||||
|
import { useGroupSelf } from "~/composables/use-groups";
|
||||||
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
||||||
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
|
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
|
||||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const { $appInfo, $globals } = useNuxtApp();
|
const { $globals } = useNuxtApp();
|
||||||
const display = useDisplay();
|
const display = useDisplay();
|
||||||
const auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
const { group } = useGroupSelf();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||||
@@ -131,7 +133,7 @@ const cookbooks = computed(() => {
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const showImageImport = computed(() => $appInfo.enableOpenaiImageServices);
|
const showImageImport = computed(() => group.value?.aiProviderSettings?.imageProviderEnabled);
|
||||||
|
|
||||||
const sidebar = ref<boolean>(false);
|
const sidebar = ref<boolean>(false);
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -205,7 +207,7 @@ const createLinks = computed(() => [
|
|||||||
insertDivider: false,
|
insertDivider: false,
|
||||||
icon: $globals.icons.fileImage,
|
icon: $globals.icons.fileImage,
|
||||||
title: i18n.t("recipe.create-from-images"),
|
title: i18n.t("recipe.create-from-images"),
|
||||||
subtitle: i18n.t("recipe.create-recipe-from-an-image"),
|
subtitle: i18n.t("recipe.create-recipe-from-images"),
|
||||||
to: `/g/${groupSlug.value}/r/create/image`,
|
to: `/g/${groupSlug.value}/r/create/image`,
|
||||||
restricted: true,
|
restricted: true,
|
||||||
hide: !showImageImport.value,
|
hide: !showImageImport.value,
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
'mt-8': section,
|
'mt-8': section,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<v-card-title class="text-h5 pl-0 py-0" style="font-weight: normal;">
|
<v-card-title :class="`text-title-${size} pl-0 py-0 d-flex align-center`" style="font-weight: normal;">
|
||||||
|
<slot name="prepend-title" />
|
||||||
<v-icon
|
<v-icon
|
||||||
v-if="icon"
|
v-if="icon"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
{{ icon }}
|
{{ icon }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
|
<slot name="append-title" />
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text
|
<v-card-text
|
||||||
v-if="$slots.default"
|
v-if="$slots.default"
|
||||||
@@ -30,11 +32,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
type Size = "large" | "medium" | "small";
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
size: {
|
||||||
|
type: String as () => Size,
|
||||||
|
default: "large",
|
||||||
|
},
|
||||||
icon: {
|
icon: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
|
|||||||
127
frontend/app/components/global/BaseKeyValueEditor.vue
Normal file
127
frontend/app/components/global/BaseKeyValueEditor.vue
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-for="(value, key) in (modelValue ?? {})"
|
||||||
|
:key="key"
|
||||||
|
class="d-flex align-center mb-2 gap-2"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
:model-value="key"
|
||||||
|
:label="resolvedKeyLabel"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
readonly
|
||||||
|
class="me-3 flex-grow-1"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
:model-value="value"
|
||||||
|
:label="resolvedValueLabel"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
class="ms-3 flex-grow-1"
|
||||||
|
@update:model-value="updateValue(key, $event)"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
@click="removeEntry(key)"
|
||||||
|
>
|
||||||
|
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-center mt-2 gap-2" @focusout="onNewEntryFocusOut">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newKey"
|
||||||
|
:label="resolvedKeyLabel"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
class="me-3 flex-grow-1"
|
||||||
|
@keydown.enter.prevent="addEntry"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="newValue"
|
||||||
|
:label="resolvedValueLabel"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
class="ms-3 flex-grow-1"
|
||||||
|
@keydown.enter.prevent="addEntry"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
:disabled="!newKey?.trim()"
|
||||||
|
@click="addEntry"
|
||||||
|
>
|
||||||
|
<v-icon>{{ $globals.icons.createAlt }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
|
|
||||||
|
const i18n = useGlobalI18n();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue?: Record<string, string> | null;
|
||||||
|
keyLabel?: string;
|
||||||
|
valueLabel?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: Record<string, string>): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
|
||||||
|
const resolvedKeyLabel = computed(() => props.keyLabel ?? i18n.t("general.key"));
|
||||||
|
const resolvedValueLabel = computed(() => props.valueLabel ?? i18n.t("general.value"));
|
||||||
|
|
||||||
|
const newKey = ref("");
|
||||||
|
const newValue = ref("");
|
||||||
|
|
||||||
|
function current(): Record<string, string> {
|
||||||
|
return { ...(props.modelValue ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEntry() {
|
||||||
|
const key = newKey.value?.trim();
|
||||||
|
if (!key) return;
|
||||||
|
const updated = current();
|
||||||
|
updated[key] = newValue.value;
|
||||||
|
emit("update:modelValue", updated);
|
||||||
|
newKey.value = "";
|
||||||
|
newValue.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNewEntryFocusOut(e: FocusEvent) {
|
||||||
|
const relatedTarget = e.relatedTarget as HTMLElement | null;
|
||||||
|
const currentTarget = e.currentTarget as HTMLElement;
|
||||||
|
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
||||||
|
addEntry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateValue(key: string, value: string) {
|
||||||
|
const updated = current();
|
||||||
|
updated[key] = value;
|
||||||
|
emit("update:modelValue", updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEntry(key: string) {
|
||||||
|
const updated = current();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||||
|
delete updated[key];
|
||||||
|
emit("update:modelValue", updated);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
55
frontend/app/composables/use-ai-providers.ts
Normal file
55
frontend/app/composables/use-ai-providers.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
|
||||||
|
|
||||||
|
export function useAIProviders() {
|
||||||
|
const api = useUserApi();
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function getOne(id: string) {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
return await api.aiProviders.getOne(id);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOne(payload: AIProviderCreate) {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
return await api.aiProviders.createOne(payload);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateOne(id: string, payload: AIProviderUpdate) {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
return await api.aiProviders.updateOne(id, payload);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteOne(id: string) {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
return await api.aiProviders.deleteOne(id);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading: readonly(loading),
|
||||||
|
getOne,
|
||||||
|
createOne,
|
||||||
|
updateOne,
|
||||||
|
deleteOne,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,7 +22,10 @@ const allAnnouncements: Announcement[] = Object.entries(_announcementsUnsorted)
|
|||||||
.map(([path, mod]) => {
|
.map(([path, mod]) => {
|
||||||
const key = path.split("/").at(-1)!.replace(".vue", "");
|
const key = path.split("/").at(-1)!.replace(".vue", "");
|
||||||
|
|
||||||
const parsed = new Date(key.split("_", 1)[0]!);
|
const dateParts = key.split("_", 1)[0]!.split("-").map(Number);
|
||||||
|
const parsed = dateParts.length === 3
|
||||||
|
? new Date(dateParts[0]!, dateParts[1]! - 1, dateParts[2]!)
|
||||||
|
: new Date(NaN);
|
||||||
const date = isNaN(parsed.getTime()) ? undefined : parsed;
|
const date = isNaN(parsed.getTime()) ? undefined : parsed;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -42,6 +42,25 @@ export const useGroupSelf = function () {
|
|||||||
|
|
||||||
return data || undefined;
|
return data || undefined;
|
||||||
},
|
},
|
||||||
|
async updateAIProviderSettings() {
|
||||||
|
if (!groupSelfRef.value) {
|
||||||
|
await refreshGroupSelf();
|
||||||
|
}
|
||||||
|
if (!groupSelfRef.value?.aiProviderSettings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await api.groups.setAIProviderSettings(groupSelfRef.value.aiProviderSettings);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
groupSelfRef.value.aiProviderSettings = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data || undefined;
|
||||||
|
},
|
||||||
|
async refresh() {
|
||||||
|
await refreshGroupSelf();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const group = actions.get();
|
const group = actions.get();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "繁體中文 (Chinese traditional)",
|
name: "繁體中文 (Chinese traditional)",
|
||||||
value: "zh-TW",
|
value: "zh-TW",
|
||||||
progress: 98,
|
progress: 97,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "never",
|
pluralFoodHandling: "never",
|
||||||
},
|
},
|
||||||
@@ -24,21 +24,21 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "Українська (Ukrainian)",
|
name: "Українська (Ukrainian)",
|
||||||
value: "uk-UA",
|
value: "uk-UA",
|
||||||
progress: 86,
|
progress: 85,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Türkçe (Turkish)",
|
name: "Türkçe (Turkish)",
|
||||||
value: "tr-TR",
|
value: "tr-TR",
|
||||||
progress: 54,
|
progress: 53,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "never",
|
pluralFoodHandling: "never",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Svenska (Swedish)",
|
name: "Svenska (Swedish)",
|
||||||
value: "sv-SE",
|
value: "sv-SE",
|
||||||
progress: 74,
|
progress: 75,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
@@ -52,42 +52,42 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "Slovenščina (Slovenian)",
|
name: "Slovenščina (Slovenian)",
|
||||||
value: "sl-SI",
|
value: "sl-SI",
|
||||||
progress: 57,
|
progress: 56,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Slovenčina (Slovak)",
|
name: "Slovenčina (Slovak)",
|
||||||
value: "sk-SK",
|
value: "sk-SK",
|
||||||
progress: 61,
|
progress: 60,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Pусский (Russian)",
|
name: "Pусский (Russian)",
|
||||||
value: "ru-RU",
|
value: "ru-RU",
|
||||||
progress: 59,
|
progress: 58,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Română (Romanian)",
|
name: "Română (Romanian)",
|
||||||
value: "ro-RO",
|
value: "ro-RO",
|
||||||
progress: 60,
|
progress: 59,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Português (Portuguese)",
|
name: "Português (Portuguese)",
|
||||||
value: "pt-PT",
|
value: "pt-PT",
|
||||||
progress: 57,
|
progress: 56,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Português do Brasil (Brazilian Portuguese)",
|
name: "Português do Brasil (Brazilian Portuguese)",
|
||||||
value: "pt-BR",
|
value: "pt-BR",
|
||||||
progress: 99,
|
progress: 98,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
@@ -129,7 +129,7 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "한국어 (Korean)",
|
name: "한국어 (Korean)",
|
||||||
value: "ko-KR",
|
value: "ko-KR",
|
||||||
progress: 55,
|
progress: 54,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "never",
|
pluralFoodHandling: "never",
|
||||||
},
|
},
|
||||||
@@ -143,35 +143,35 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "Italiano (Italian)",
|
name: "Italiano (Italian)",
|
||||||
value: "it-IT",
|
value: "it-IT",
|
||||||
progress: 73,
|
progress: 72,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Íslenska (Icelandic)",
|
name: "Íslenska (Icelandic)",
|
||||||
value: "is-IS",
|
value: "is-IS",
|
||||||
progress: 57,
|
progress: 56,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Magyar (Hungarian)",
|
name: "Magyar (Hungarian)",
|
||||||
value: "hu-HU",
|
value: "hu-HU",
|
||||||
progress: 61,
|
progress: 62,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Hrvatski (Croatian)",
|
name: "Hrvatski (Croatian)",
|
||||||
value: "hr-HR",
|
value: "hr-HR",
|
||||||
progress: 42,
|
progress: 41,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "עברית (Hebrew)",
|
name: "עברית (Hebrew)",
|
||||||
value: "he-IL",
|
value: "he-IL",
|
||||||
progress: 73,
|
progress: 72,
|
||||||
dir: "rtl",
|
dir: "rtl",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
@@ -192,7 +192,7 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "Français canadien (Canadian French)",
|
name: "Français canadien (Canadian French)",
|
||||||
value: "fr-CA",
|
value: "fr-CA",
|
||||||
progress: 90,
|
progress: 89,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
@@ -206,7 +206,7 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "Suomi (Finnish)",
|
name: "Suomi (Finnish)",
|
||||||
value: "fi-FI",
|
value: "fi-FI",
|
||||||
progress: 99,
|
progress: 98,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
@@ -241,21 +241,21 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "Ελληνικά (Greek)",
|
name: "Ελληνικά (Greek)",
|
||||||
value: "el-GR",
|
value: "el-GR",
|
||||||
progress: 57,
|
progress: 58,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Deutsch (German)",
|
name: "Deutsch (German)",
|
||||||
value: "de-DE",
|
value: "de-DE",
|
||||||
progress: 99,
|
progress: 98,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Dansk (Danish)",
|
name: "Dansk (Danish)",
|
||||||
value: "da-DK",
|
value: "da-DK",
|
||||||
progress: 100,
|
progress: 99,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
@@ -269,7 +269,7 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "Català (Catalan)",
|
name: "Català (Catalan)",
|
||||||
value: "ca-ES",
|
value: "ca-ES",
|
||||||
progress: 60,
|
progress: 59,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
@@ -283,14 +283,14 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "العربية (Arabic)",
|
name: "العربية (Arabic)",
|
||||||
value: "ar-SA",
|
value: "ar-SA",
|
||||||
progress: 98,
|
progress: 97,
|
||||||
dir: "rtl",
|
dir: "rtl",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Afrikaans (Afrikaans)",
|
name: "Afrikaans (Afrikaans)",
|
||||||
value: "af-ZA",
|
value: "af-ZA",
|
||||||
progress: 37,
|
progress: 36,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
pluralFoodHandling: "always",
|
pluralFoodHandling: "always",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Create a new recipe from scratch.",
|
"create-recipe-description": "Create a new recipe from scratch.",
|
||||||
"create-recipes": "Create Recipes",
|
"create-recipes": "Create Recipes",
|
||||||
"import-with-zip": "Voer in met .zip",
|
"import-with-zip": "Voer in met .zip",
|
||||||
"create-recipe-from-an-image": "Create Recipe from an Image",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
||||||
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
|
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
|
||||||
"create-from-images": "Create from Images",
|
"create-from-images": "Create from Images",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "إنشاء وصفة جديدة من الصفر.",
|
"create-recipe-description": "إنشاء وصفة جديدة من الصفر.",
|
||||||
"create-recipes": "إنشاء الوصفات",
|
"create-recipes": "إنشاء الوصفات",
|
||||||
"import-with-zip": "الاستيراد باستخدام zip.",
|
"import-with-zip": "الاستيراد باستخدام zip.",
|
||||||
"create-recipe-from-an-image": "إنشاء وصفة عن طريق صورة",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
||||||
"crop-and-rotate-the-image": "قم بقص الصورة وتدويرها بحيث يظهر النص فقط، ويكون في الاتجاه الصحيح.",
|
"crop-and-rotate-the-image": "قم بقص الصورة وتدويرها بحيث يظهر النص فقط، ويكون في الاتجاه الصحيح.",
|
||||||
"create-from-images": "إنشاء عن طريق صور",
|
"create-from-images": "إنشاء عن طريق صور",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Създайте нова рецепта от чернова.",
|
"create-recipe-description": "Създайте нова рецепта от чернова.",
|
||||||
"create-recipes": "Създайте рецепти",
|
"create-recipes": "Създайте рецепти",
|
||||||
"import-with-zip": "Импортирай от .zip",
|
"import-with-zip": "Импортирай от .zip",
|
||||||
"create-recipe-from-an-image": "Create Recipe from an Image",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
||||||
"crop-and-rotate-the-image": "Изрежете и завъртете изображението, така че да се вижда само текстът и той да е в правилната ориентация.",
|
"crop-and-rotate-the-image": "Изрежете и завъртете изображението, така че да се вижда само текстът и той да е в правилната ориентация.",
|
||||||
"create-from-images": "Създаване от изображения",
|
"create-from-images": "Създаване от изображения",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Crea una nova recepta des de zero.",
|
"create-recipe-description": "Crea una nova recepta des de zero.",
|
||||||
"create-recipes": "Crea Receptes",
|
"create-recipes": "Crea Receptes",
|
||||||
"import-with-zip": "Importar amb un .zip",
|
"import-with-zip": "Importar amb un .zip",
|
||||||
"create-recipe-from-an-image": "Crear una recepta a partir d'una imatge",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Crear una recepta pujant una imatge d'ella. Mealie intentarà extreure el text de la imatge mitjançant IA i crear-ne la recepta.",
|
"create-recipe-from-an-image-description": "Crear una recepta pujant una imatge d'ella. Mealie intentarà extreure el text de la imatge mitjançant IA i crear-ne la recepta.",
|
||||||
"crop-and-rotate-the-image": "Retalla i rota la imatge, per tal que només el text sigui visible, i estigui orientat correctament.",
|
"crop-and-rotate-the-image": "Retalla i rota la imatge, per tal que només el text sigui visible, i estigui orientat correctament.",
|
||||||
"create-from-images": "Crear una recepta a partir d'una imatge",
|
"create-from-images": "Crear una recepta a partir d'una imatge",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Vytvořit nový recept od nuly.",
|
"create-recipe-description": "Vytvořit nový recept od nuly.",
|
||||||
"create-recipes": "Vytvořit recepty",
|
"create-recipes": "Vytvořit recepty",
|
||||||
"import-with-zip": "Importovat pomocí .zip",
|
"import-with-zip": "Importovat pomocí .zip",
|
||||||
"create-recipe-from-an-image": "Vytvořit recept z obrázku",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Vytvořte recept nahráním obrázku. Mealie se pokusí z obrázku extrahovat text pomocí AI a vytvořit z něj recept.",
|
"create-recipe-from-an-image-description": "Vytvořte recept nahráním obrázku. Mealie se pokusí z obrázku extrahovat text pomocí AI a vytvořit z něj recept.",
|
||||||
"crop-and-rotate-the-image": "Oříznout a otočit obrázek tak, aby byl viditelný pouze text a aby byl ve správné orientaci.",
|
"crop-and-rotate-the-image": "Oříznout a otočit obrázek tak, aby byl viditelný pouze text a aby byl ve správné orientaci.",
|
||||||
"create-from-images": "Vytvořit z obrázků",
|
"create-from-images": "Vytvořit z obrázků",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Opret ny opskrift fra bunden.",
|
"create-recipe-description": "Opret ny opskrift fra bunden.",
|
||||||
"create-recipes": "Opret opskrift",
|
"create-recipes": "Opret opskrift",
|
||||||
"import-with-zip": "Importér fra ZIP-fil",
|
"import-with-zip": "Importér fra ZIP-fil",
|
||||||
"create-recipe-from-an-image": "Opret opskrift fra et billede",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Opret en opskrift ved at overføre et billede af den. Mealie vil forsøge at udtrække teksten fra billedet med AI og oprette en opskrift fra det.",
|
"create-recipe-from-an-image-description": "Opret en opskrift ved at overføre et billede af den. Mealie vil forsøge at udtrække teksten fra billedet med AI og oprette en opskrift fra det.",
|
||||||
"crop-and-rotate-the-image": "Beskær og roter billedet, så kun teksten er synlig, og det vises i den rigtige retning.",
|
"crop-and-rotate-the-image": "Beskær og roter billedet, så kun teksten er synlig, og det vises i den rigtige retning.",
|
||||||
"create-from-images": "Opret fra billede",
|
"create-from-images": "Opret fra billede",
|
||||||
|
|||||||
@@ -169,7 +169,7 @@
|
|||||||
"token": "Token",
|
"token": "Token",
|
||||||
"tuesday": "Dienstag",
|
"tuesday": "Dienstag",
|
||||||
"type": "Typ",
|
"type": "Typ",
|
||||||
"undo": "Undo",
|
"undo": "Rückgängig",
|
||||||
"update": "Aktualisieren",
|
"update": "Aktualisieren",
|
||||||
"updated": "Aktualisiert",
|
"updated": "Aktualisiert",
|
||||||
"upload": "Hochladen",
|
"upload": "Hochladen",
|
||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Erstelle ein neues Rezept von Grund auf.",
|
"create-recipe-description": "Erstelle ein neues Rezept von Grund auf.",
|
||||||
"create-recipes": "Rezepte erstellen",
|
"create-recipes": "Rezepte erstellen",
|
||||||
"import-with-zip": "Von .zip importieren",
|
"import-with-zip": "Von .zip importieren",
|
||||||
"create-recipe-from-an-image": "Rezept von einem Bild erstellen",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Erstelle ein Rezept, indem du ein Bild hochlädst. Mealie wird versuchen, den Text aus dem Bild mit Hilfe von KI zu extrahieren und ein Rezept daraus zu erstellen.",
|
"create-recipe-from-an-image-description": "Erstelle ein Rezept, indem du ein Bild hochlädst. Mealie wird versuchen, den Text aus dem Bild mit Hilfe von KI zu extrahieren und ein Rezept daraus zu erstellen.",
|
||||||
"crop-and-rotate-the-image": "Beschneide und drehe das Bild so, dass nur der Text zu sehen ist und die Ausrichtung stimmt.",
|
"crop-and-rotate-the-image": "Beschneide und drehe das Bild so, dass nur der Text zu sehen ist und die Ausrichtung stimmt.",
|
||||||
"create-from-images": "Aus Bildern erstellen",
|
"create-from-images": "Aus Bildern erstellen",
|
||||||
@@ -917,7 +917,7 @@
|
|||||||
"quantity": "Menge: {0}",
|
"quantity": "Menge: {0}",
|
||||||
"shopping-list": "Einkaufsliste",
|
"shopping-list": "Einkaufsliste",
|
||||||
"shopping-lists": "Einkaufslisten",
|
"shopping-lists": "Einkaufslisten",
|
||||||
"add-item": "Add item",
|
"add-item": "Eintrag hinzufügen",
|
||||||
"food": "Lebensmittel",
|
"food": "Lebensmittel",
|
||||||
"note": "Notiz",
|
"note": "Notiz",
|
||||||
"label": "Kategorie",
|
"label": "Kategorie",
|
||||||
@@ -1478,10 +1478,10 @@
|
|||||||
"max-length": "Muss maximal {max} Zeichen haben|Muss maximal {max} Zeichen haben"
|
"max-length": "Muss maximal {max} Zeichen haben|Muss maximal {max} Zeichen haben"
|
||||||
},
|
},
|
||||||
"announcements": {
|
"announcements": {
|
||||||
"announcements": "Announcements",
|
"announcements": "Ankündigungen",
|
||||||
"all-announcements": "All announcements",
|
"all-announcements": "Alle Ankündigungen",
|
||||||
"mark-all-as-read": "Alle als gelesen markieren",
|
"mark-all-as-read": "Alle als gelesen markieren",
|
||||||
"show-announcements-from-mealie": "Show announcements from Mealie",
|
"show-announcements-from-mealie": "Ankündigung von Mealie anzeigen",
|
||||||
"show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings"
|
"show-announcements-setting-description": "Lege fest, ob Benutzer Ankündigungen von Mealie sehen dürfen. Wenn aktiviert, können Benutzer die Anzeige in ihren Benutzereinstellungen immer noch deaktivieren"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Δημιουργήστε μια νέα συνταγή από το μηδέν.",
|
"create-recipe-description": "Δημιουργήστε μια νέα συνταγή από το μηδέν.",
|
||||||
"create-recipes": "Δημιουργία Συνταγών",
|
"create-recipes": "Δημιουργία Συνταγών",
|
||||||
"import-with-zip": "Εισαγωγή μέσω .zip",
|
"import-with-zip": "Εισαγωγή μέσω .zip",
|
||||||
"create-recipe-from-an-image": "Δημιουργία συνταγής από μια εικόνα",
|
"create-recipe-from-images": "Δημιουργία συνταγής από εικόνες",
|
||||||
"create-recipe-from-an-image-description": "Δημιουργήστε μια συνταγή ανεβάζοντας μια εικόνα της. Το Mealie θα προσπαθήσει να εξάγει το κείμενο από την εικόνα χρησιμοποιώντας τεχνητή νοημοσύνη και να δημιουργήσει μια συνταγή από αυτό.",
|
"create-recipe-from-an-image-description": "Δημιουργήστε μια συνταγή ανεβάζοντας μια εικόνα της. Το Mealie θα προσπαθήσει να εξάγει το κείμενο από την εικόνα χρησιμοποιώντας τεχνητή νοημοσύνη και να δημιουργήσει μια συνταγή από αυτό.",
|
||||||
"crop-and-rotate-the-image": "Περικοπή και περιστροφή της εικόνας, έτσι ώστε να είναι μόνο το κείμενο ορατό και να είναι στο σωστό προσανατολισμό.",
|
"crop-and-rotate-the-image": "Περικοπή και περιστροφή της εικόνας, έτσι ώστε να είναι μόνο το κείμενο ορατό και να είναι στο σωστό προσανατολισμό.",
|
||||||
"create-from-images": "Δημιουργία από εικόνες",
|
"create-from-images": "Δημιουργία από εικόνες",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Create a new recipe from scratch.",
|
"create-recipe-description": "Create a new recipe from scratch.",
|
||||||
"create-recipes": "Create Recipes",
|
"create-recipes": "Create Recipes",
|
||||||
"import-with-zip": "Import with .zip",
|
"import-with-zip": "Import with .zip",
|
||||||
"create-recipe-from-an-image": "Create Recipe from an Image",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
||||||
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
|
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
|
||||||
"create-from-images": "Create from Images",
|
"create-from-images": "Create from Images",
|
||||||
|
|||||||
@@ -223,7 +223,9 @@
|
|||||||
"show-advanced": "Show Advanced",
|
"show-advanced": "Show Advanced",
|
||||||
"add-field": "Add Field",
|
"add-field": "Add Field",
|
||||||
"date-created": "Date Created",
|
"date-created": "Date Created",
|
||||||
"date-updated": "Date Updated"
|
"date-updated": "Date Updated",
|
||||||
|
"key": "Key",
|
||||||
|
"value": "Value"
|
||||||
},
|
},
|
||||||
"group": {
|
"group": {
|
||||||
"are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete <b>{groupName}<b/>?",
|
"are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete <b>{groupName}<b/>?",
|
||||||
@@ -283,7 +285,40 @@
|
|||||||
"admin-group-management-text": "Changes to this group will be reflected immediately.",
|
"admin-group-management-text": "Changes to this group will be reflected immediately.",
|
||||||
"group-id-value": "Group Id: {0}",
|
"group-id-value": "Group Id: {0}",
|
||||||
"total-households": "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": "You must select a group before selecting a household",
|
||||||
|
"ai-provider-settings": {
|
||||||
|
"ai-provider-settings": "AI Provider Settings",
|
||||||
|
"ai-provider": "AI Provider",
|
||||||
|
"ai-providers": "AI Providers",
|
||||||
|
"ai-provider-settings-description": "Configure AI providers to enable AI-powered features, such as enhanced ingredient parsing, creating recipes from videos, and more!",
|
||||||
|
"providers": "Providers",
|
||||||
|
"create-provider": "Create Provider",
|
||||||
|
"edit-provider": "Edit Provider",
|
||||||
|
"default-provider": "Default Provider",
|
||||||
|
"default-provider-description": "Required to enable AI features",
|
||||||
|
"audio-provider": "Audio Provider",
|
||||||
|
"audio-provider-description": "Enables audio transcription features, such as creating recipes from videos",
|
||||||
|
"image-provider": "Image Provider",
|
||||||
|
"image-provider-description": "Enables image recognition features, such as creating recipes from images",
|
||||||
|
"provider-name": "Provider Name",
|
||||||
|
"api-key": "API Key",
|
||||||
|
"api-key-description-create": "Your provider's API key for authentication. If your service (e.g. Ollama) doesn't use an API key, you still have to put something here.",
|
||||||
|
"api-key-description-edit": "Leave this blank unless you want to change it.",
|
||||||
|
"base-url": "Base URL",
|
||||||
|
"base-url-description": "If you're using OpenAI leave this blank. Must be an OpenAI-compatible endpoint (e.g. \"http://localhost:11434/v1\").",
|
||||||
|
"model": "Model",
|
||||||
|
"model-description": "Which model your AI provider should use (e.g. \"gpt-5\").",
|
||||||
|
"request-timeout-seconds": "Request Timeout (seconds)",
|
||||||
|
"provider-created": "Provider created",
|
||||||
|
"provider-updated": "Provider updated",
|
||||||
|
"provider-deleted": "Provider deleted",
|
||||||
|
"provider-create-failed": "Failed to create provider",
|
||||||
|
"provider-update-failed": "Failed to update provider",
|
||||||
|
"provider-delete-failed": "Failed to delete provider",
|
||||||
|
"request-headers": "Request Headers",
|
||||||
|
"request-params": "Request Parameters",
|
||||||
|
"no-default-provider-warning": "You have not set a default provider, so AI features are disabled"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"household": {
|
"household": {
|
||||||
"household": "Household",
|
"household": "Household",
|
||||||
@@ -628,7 +663,7 @@
|
|||||||
"create-recipe-description": "Create a new recipe from scratch.",
|
"create-recipe-description": "Create a new recipe from scratch.",
|
||||||
"create-recipes": "Create Recipes",
|
"create-recipes": "Create Recipes",
|
||||||
"import-with-zip": "Import with .zip",
|
"import-with-zip": "Import with .zip",
|
||||||
"create-recipe-from-an-image": "Create Recipe from Images",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Create a recipe by uploading images of the recipe text. Mealie will attempt to extract the text from the images using AI and create a new recipe from it.",
|
"create-recipe-from-an-image-description": "Create a recipe by uploading images of the recipe text. Mealie will attempt to extract the text from the images using AI and create a new recipe from it.",
|
||||||
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
|
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
|
||||||
"create-from-images": "Create from Images",
|
"create-from-images": "Create from Images",
|
||||||
@@ -1362,6 +1397,8 @@
|
|||||||
"already-set-up-bring-to-homepage": "I'm already set up, just bring me to the homepage",
|
"already-set-up-bring-to-homepage": "I'm already set up, just bring me to the homepage",
|
||||||
"common-settings-for-new-sites": "Here are some common settings for new sites",
|
"common-settings-for-new-sites": "Here are some common settings for new sites",
|
||||||
"setup-complete": "Setup Complete!",
|
"setup-complete": "Setup Complete!",
|
||||||
|
|
||||||
|
"ai-providers-description": "Optionally configure AI providers for your group. AI providers enable features like creating recipes from images, importing recipes from videos, and enhanced ingredient parsing. You can always configure this later from your group settings.",
|
||||||
"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": "Here are a few things to help you get started with Mealie",
|
||||||
"restore-from-v1-backup": "Have a backup from a previous instance of Mealie v1? You can restore it here.",
|
"restore-from-v1-backup": "Have a backup from a previous instance of Mealie v1? You can restore it here.",
|
||||||
"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": "Manage your own profile, or grab an invite link to share with others."
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Crear nueva receta desde cero.",
|
"create-recipe-description": "Crear nueva receta desde cero.",
|
||||||
"create-recipes": "Crear Recetas",
|
"create-recipes": "Crear Recetas",
|
||||||
"import-with-zip": "Importar desde .zip",
|
"import-with-zip": "Importar desde .zip",
|
||||||
"create-recipe-from-an-image": "Crear receta a partir de una imagen",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Crea una receta cargando una imagen de ella. Mealie intentará extraer el texto de la imagen usando IA y crear una receta de ella.",
|
"create-recipe-from-an-image-description": "Crea una receta cargando una imagen de ella. Mealie intentará extraer el texto de la imagen usando IA y crear una receta de ella.",
|
||||||
"crop-and-rotate-the-image": "Recortar y rotar la imagen de manera que sólo el texto sea visible, y esté en la orientación correcta.",
|
"crop-and-rotate-the-image": "Recortar y rotar la imagen de manera que sólo el texto sea visible, y esté en la orientación correcta.",
|
||||||
"create-from-images": "Crear a partir de imágenes",
|
"create-from-images": "Crear a partir de imágenes",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Loo uus retsept algusest",
|
"create-recipe-description": "Loo uus retsept algusest",
|
||||||
"create-recipes": "Loo retseptid",
|
"create-recipes": "Loo retseptid",
|
||||||
"import-with-zip": "Impordi .zip failist",
|
"import-with-zip": "Impordi .zip failist",
|
||||||
"create-recipe-from-an-image": "Retsepti loomine pildist",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Retsepti loomiseks lae üles selle pilt. Mealie üritab ekstraheerida pildil oleva teksti ning luua retsepti sellest kasutades AI-d.",
|
"create-recipe-from-an-image-description": "Retsepti loomiseks lae üles selle pilt. Mealie üritab ekstraheerida pildil oleva teksti ning luua retsepti sellest kasutades AI-d.",
|
||||||
"crop-and-rotate-the-image": "Kärpige ja pöörake pilti nii, et ainult tekst oleks nähtaval ja see oleks suunatud ülespoole.",
|
"crop-and-rotate-the-image": "Kärpige ja pöörake pilti nii, et ainult tekst oleks nähtaval ja see oleks suunatud ülespoole.",
|
||||||
"create-from-images": "Retsepti loomine pildist",
|
"create-from-images": "Retsepti loomine pildist",
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
"category": "Kategoria"
|
"category": "Kategoria"
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"apprise-url": "Apprise URL",
|
"apprise-url": "Apprise-url",
|
||||||
"database": "Tietokanta",
|
"database": "Tietokanta",
|
||||||
"delete-event": "Poista tapahtuma",
|
"delete-event": "Poista tapahtuma",
|
||||||
"event-delete-confirmation": "Haluatko varmasti poistaa tämän tapahtuman?",
|
"event-delete-confirmation": "Haluatko varmasti poistaa tämän tapahtuman?",
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
"dashboard": "Hallintanäkymä",
|
"dashboard": "Hallintanäkymä",
|
||||||
"delete": "Poista",
|
"delete": "Poista",
|
||||||
"disabled": "Poistettu käytöstä",
|
"disabled": "Poistettu käytöstä",
|
||||||
"done": "Done",
|
"done": "Valmis",
|
||||||
"download": "Lataa",
|
"download": "Lataa",
|
||||||
"duplicate": "Monista",
|
"duplicate": "Monista",
|
||||||
"edit": "Muokkaa",
|
"edit": "Muokkaa",
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
"token": "Tunniste",
|
"token": "Tunniste",
|
||||||
"tuesday": "Tiistai",
|
"tuesday": "Tiistai",
|
||||||
"type": "Tyyppi",
|
"type": "Tyyppi",
|
||||||
"undo": "Undo",
|
"undo": "Peru",
|
||||||
"update": "Päivitä",
|
"update": "Päivitä",
|
||||||
"updated": "Päivitetty",
|
"updated": "Päivitetty",
|
||||||
"upload": "Lähetä",
|
"upload": "Lähetä",
|
||||||
@@ -333,8 +333,8 @@
|
|||||||
"any-household": "Mikä tahansa kotitalous",
|
"any-household": "Mikä tahansa kotitalous",
|
||||||
"no-meal-plan-defined-yet": "Ateriasuunnitelmaa ei ole vielä määritelty",
|
"no-meal-plan-defined-yet": "Ateriasuunnitelmaa ei ole vielä määritelty",
|
||||||
"no-meal-planned-for-today": "Ei ateriasuunnitelmaa tälle päivälle",
|
"no-meal-planned-for-today": "Ei ateriasuunnitelmaa tälle päivälle",
|
||||||
"numberOfDaysPast-hint": "Number of days in the past on page load",
|
"numberOfDaysPast-hint": "Menneisyydestä ladattujen päivien määrä",
|
||||||
"numberOfDaysPast-label": "Default Days in the Past",
|
"numberOfDaysPast-label": "Oletusarvo menneiden päivien lataukselle",
|
||||||
"numberOfDays-hint": "Sivun latauspäivien lukumäärä",
|
"numberOfDays-hint": "Sivun latauspäivien lukumäärä",
|
||||||
"numberOfDays-label": "Oletuspäivät",
|
"numberOfDays-label": "Oletuspäivät",
|
||||||
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Vain näiden luokkien reseptejä käytetään ateriasuunnitelmissa",
|
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Vain näiden luokkien reseptejä käytetään ateriasuunnitelmissa",
|
||||||
@@ -392,7 +392,7 @@
|
|||||||
"nextcloud": {
|
"nextcloud": {
|
||||||
"description": "Tuo tiedot Nextcloudin Cookbookista",
|
"description": "Tuo tiedot Nextcloudin Cookbookista",
|
||||||
"description-long": "Nextcloud reseptejä voidaan tuoda zip-tiedostosta, joka sisältää Nextcloudin tallennetut tiedot. Katso esimerkkikansiorakenne alla varmistaaksesi, että reseptisi voidaan tuoda.",
|
"description-long": "Nextcloud reseptejä voidaan tuoda zip-tiedostosta, joka sisältää Nextcloudin tallennetut tiedot. Katso esimerkkikansiorakenne alla varmistaaksesi, että reseptisi voidaan tuoda.",
|
||||||
"title": "Nextcloud Cookbook"
|
"title": "Nextcloud-keittokirja"
|
||||||
},
|
},
|
||||||
"copymethat": {
|
"copymethat": {
|
||||||
"description-long": "Mealie voi tuoda reseptejä Copy Me That -sovelluksesta. Vie reseptisi HTML-muodossa ja lataa sitten zip-tiedosto.",
|
"description-long": "Mealie voi tuoda reseptejä Copy Me That -sovelluksesta. Vie reseptisi HTML-muodossa ja lataa sitten zip-tiedosto.",
|
||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Luo resepti alusta.",
|
"create-recipe-description": "Luo resepti alusta.",
|
||||||
"create-recipes": "Luo reseptejä",
|
"create-recipes": "Luo reseptejä",
|
||||||
"import-with-zip": "Tuo .zip:llä",
|
"import-with-zip": "Tuo .zip:llä",
|
||||||
"create-recipe-from-an-image": "Luo resepti kuvasta",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Luo resepti tuomalla siitä kuva. Mealie pyrkii poimimaan tekstin kuvasta tekoälyllä ja luomaan siitä reseptin.",
|
"create-recipe-from-an-image-description": "Luo resepti tuomalla siitä kuva. Mealie pyrkii poimimaan tekstin kuvasta tekoälyllä ja luomaan siitä reseptin.",
|
||||||
"crop-and-rotate-the-image": "Rajaa ja kierrä kuvaa niin, että vain teksti näkyy, ja että se on oikein päin.",
|
"crop-and-rotate-the-image": "Rajaa ja kierrä kuvaa niin, että vain teksti näkyy, ja että se on oikein päin.",
|
||||||
"create-from-images": "Luo resepti kuvasta",
|
"create-from-images": "Luo resepti kuvasta",
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
"confidence-score": "Varmuuspisteet",
|
"confidence-score": "Varmuuspisteet",
|
||||||
"ingredient-parser-description": "Ainesosat on haettu onnistuneesti. Ole hyvä ja tarkista ainesosat joista emme ole varmoja.",
|
"ingredient-parser-description": "Ainesosat on haettu onnistuneesti. Ole hyvä ja tarkista ainesosat joista emme ole varmoja.",
|
||||||
"ingredient-parser-final-review-description": "Kun kaikki ainesosat on tarkistettu, sinulla on vielä yksi mahdollisuus tarkistaa kaikki ainesosat ennen kuin muokkaat reseptiäsi.",
|
"ingredient-parser-final-review-description": "Kun kaikki ainesosat on tarkistettu, sinulla on vielä yksi mahdollisuus tarkistaa kaikki ainesosat ennen kuin muokkaat reseptiäsi.",
|
||||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
"add-text-as-alias-for-item": "Lisää \"{text}\" kohteen {item} aliakseksi",
|
||||||
"delete-item": "Poista kohde"
|
"delete-item": "Poista kohde"
|
||||||
},
|
},
|
||||||
"reset-servings-count": "Palauta Annoksien Määrä",
|
"reset-servings-count": "Palauta Annoksien Määrä",
|
||||||
@@ -893,17 +893,17 @@
|
|||||||
"server-side-base-url-error-text": "`BASE_URL` on API-palvelimen oletusarvo. Tämä aiheuttaa ongelmia ilmoitusten linkkien kanssa, jotka on luotu palvelimella sähköposteja varten jne.",
|
"server-side-base-url-error-text": "`BASE_URL` on API-palvelimen oletusarvo. Tämä aiheuttaa ongelmia ilmoitusten linkkien kanssa, jotka on luotu palvelimella sähköposteja varten jne.",
|
||||||
"server-side-base-url-success-text": "Palvelimen nettiosoite ei vastaa oletusta",
|
"server-side-base-url-success-text": "Palvelimen nettiosoite ei vastaa oletusta",
|
||||||
"ldap-ready": "LDAP Valmis",
|
"ldap-ready": "LDAP Valmis",
|
||||||
"ldap-not-ready": "LDAP Not Ready",
|
"ldap-not-ready": "LDAP ei valmis",
|
||||||
"ldap-ready-error-text": "Kaikkia LDAP-arvoja ei ole määritetty. Tämä voidaan ohittaa, jos et käytä LDAP-todennusta.",
|
"ldap-ready-error-text": "Kaikkia LDAP-arvoja ei ole määritetty. Tämä voidaan ohittaa, jos et käytä LDAP-todennusta.",
|
||||||
"ldap-ready-success-text": "Kaikki vaaditut LDAP-muuttujat on asetettu.",
|
"ldap-ready-success-text": "Kaikki vaaditut LDAP-muuttujat on asetettu.",
|
||||||
"build": "Koonti",
|
"build": "Koonti",
|
||||||
"recipe-scraper-version": "Reseptikaappaimen versio",
|
"recipe-scraper-version": "Reseptikaappaimen versio",
|
||||||
"oidc-ready": "OIDC valmis",
|
"oidc-ready": "OIDC valmis",
|
||||||
"oidc-not-ready": "OIDC Not Ready",
|
"oidc-not-ready": "OIDC ei ole valmis",
|
||||||
"oidc-ready-error-text": "Kaikkia OIDC-arvoja ei ole määritelty. Jos et käytä OIDC-todennusta, voidaan asia jättää huomiotta.",
|
"oidc-ready-error-text": "Kaikkia OIDC-arvoja ei ole määritelty. Jos et käytä OIDC-todennusta, voidaan asia jättää huomiotta.",
|
||||||
"oidc-ready-success-text": "Kaikki vaaditut OIDC-muuttujat asetettu.",
|
"oidc-ready-success-text": "Kaikki vaaditut OIDC-muuttujat asetettu.",
|
||||||
"openai-ready": "OpenAI valmis",
|
"openai-ready": "OpenAI valmis",
|
||||||
"openai-not-ready": "OpenAI Not Ready",
|
"openai-not-ready": "OpenAI ei ole valmis",
|
||||||
"openai-ready-error-text": "Kaikkia OpenAI:n arvoja ei ole määritelty. Tämä voidaan sivuuttaa, mikäli et käytä OpenAI:n toimintoja.",
|
"openai-ready-error-text": "Kaikkia OpenAI:n arvoja ei ole määritelty. Tämä voidaan sivuuttaa, mikäli et käytä OpenAI:n toimintoja.",
|
||||||
"openai-ready-success-text": "Vaadittavat OpenAI-muuttujat ovat asetetut."
|
"openai-ready-success-text": "Vaadittavat OpenAI-muuttujat ovat asetetut."
|
||||||
},
|
},
|
||||||
@@ -917,7 +917,7 @@
|
|||||||
"quantity": "Määrä: {0}",
|
"quantity": "Määrä: {0}",
|
||||||
"shopping-list": "Ostoslista",
|
"shopping-list": "Ostoslista",
|
||||||
"shopping-lists": "Ostoslistat",
|
"shopping-lists": "Ostoslistat",
|
||||||
"add-item": "Add item",
|
"add-item": "Lisää kohde",
|
||||||
"food": "Elintarvikkeet",
|
"food": "Elintarvikkeet",
|
||||||
"note": "Muistiinpano",
|
"note": "Muistiinpano",
|
||||||
"label": "Tunnus",
|
"label": "Tunnus",
|
||||||
@@ -962,7 +962,7 @@
|
|||||||
"language": "Kieli",
|
"language": "Kieli",
|
||||||
"maintenance": "Ylläpito",
|
"maintenance": "Ylläpito",
|
||||||
"background-tasks": "Taustatehtävät",
|
"background-tasks": "Taustatehtävät",
|
||||||
"parser": "Parser",
|
"parser": "Jäsentäjä",
|
||||||
"developer": "Kehittäjä",
|
"developer": "Kehittäjä",
|
||||||
"cookbook": "Keittokirja",
|
"cookbook": "Keittokirja",
|
||||||
"create-cookbook": "Luo uusi keittokirja"
|
"create-cookbook": "Luo uusi keittokirja"
|
||||||
@@ -1351,7 +1351,7 @@
|
|||||||
"ingredient-text": "Ainesosan Teksti",
|
"ingredient-text": "Ainesosan Teksti",
|
||||||
"average-confident": "{0} Luottamus",
|
"average-confident": "{0} Luottamus",
|
||||||
"try-an-example": "Kokeile esimerkkiä",
|
"try-an-example": "Kokeile esimerkkiä",
|
||||||
"parser": "Parser",
|
"parser": "Jäsentäjä",
|
||||||
"background-tasks": "Taustatehtävät",
|
"background-tasks": "Taustatehtävät",
|
||||||
"background-tasks-description": "Täältä voit tarkastella kaikkia käynnissä olevia taustatehtäviä ja niiden tilaa",
|
"background-tasks-description": "Täältä voit tarkastella kaikkia käynnissä olevia taustatehtäviä ja niiden tilaa",
|
||||||
"no-logs-found": "Lokeja Ei Löytynyt",
|
"no-logs-found": "Lokeja Ei Löytynyt",
|
||||||
@@ -1481,7 +1481,7 @@
|
|||||||
"announcements": "Announcements",
|
"announcements": "Announcements",
|
||||||
"all-announcements": "All announcements",
|
"all-announcements": "All announcements",
|
||||||
"mark-all-as-read": "Mark All as Read",
|
"mark-all-as-read": "Mark All as Read",
|
||||||
"show-announcements-from-mealie": "Show announcements from Mealie",
|
"show-announcements-from-mealie": "Näytä Mealien ilmoitukset",
|
||||||
"show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings"
|
"show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Créer une nouvelle recette de zéro.",
|
"create-recipe-description": "Créer une nouvelle recette de zéro.",
|
||||||
"create-recipes": "Créer des recettes",
|
"create-recipes": "Créer des recettes",
|
||||||
"import-with-zip": "Importer un .zip",
|
"import-with-zip": "Importer un .zip",
|
||||||
"create-recipe-from-an-image": "Créer une recette à partir d’une image",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Créez une recette en téléchargeant une image de celle-ci. Mealie utilisera l’IA pour tenter d’extraire le texte et de créer une recette.",
|
"create-recipe-from-an-image-description": "Créez une recette en téléchargeant une image de celle-ci. Mealie utilisera l’IA pour tenter d’extraire le texte et de créer une recette.",
|
||||||
"crop-and-rotate-the-image": "Rogner et pivoter l’image pour que seul le texte soit visible, et qu’il soit dans la bonne orientation.",
|
"crop-and-rotate-the-image": "Rogner et pivoter l’image pour que seul le texte soit visible, et qu’il soit dans la bonne orientation.",
|
||||||
"create-from-images": "Créer à partir d’une image",
|
"create-from-images": "Créer à partir d’une image",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Créer une nouvelle recette à partir de zéro.",
|
"create-recipe-description": "Créer une nouvelle recette à partir de zéro.",
|
||||||
"create-recipes": "Créer des recettes",
|
"create-recipes": "Créer des recettes",
|
||||||
"import-with-zip": "Importer un .zip",
|
"import-with-zip": "Importer un .zip",
|
||||||
"create-recipe-from-an-image": "Créer une recette à partir d’une image",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Créez une recette en téléversant une image de celle-ci. Mealie utilisera l’IA pour tenter d’extraire le texte et de créer une recette.",
|
"create-recipe-from-an-image-description": "Créez une recette en téléversant une image de celle-ci. Mealie utilisera l’IA pour tenter d’extraire le texte et de créer une recette.",
|
||||||
"crop-and-rotate-the-image": "Rogner et pivoter l’image pour que seul le texte soit visible et qu’il soit dans la bonne orientation.",
|
"crop-and-rotate-the-image": "Rogner et pivoter l’image pour que seul le texte soit visible et qu’il soit dans la bonne orientation.",
|
||||||
"create-from-images": "Créer à partir d’images",
|
"create-from-images": "Créer à partir d’images",
|
||||||
|
|||||||
@@ -169,7 +169,7 @@
|
|||||||
"token": "Jeton",
|
"token": "Jeton",
|
||||||
"tuesday": "Mardi",
|
"tuesday": "Mardi",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"undo": "Undo",
|
"undo": "Annuler",
|
||||||
"update": "Mettre à jour",
|
"update": "Mettre à jour",
|
||||||
"updated": "Mis à jour",
|
"updated": "Mis à jour",
|
||||||
"upload": "Importer",
|
"upload": "Importer",
|
||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Créer une nouvelle recette de zéro.",
|
"create-recipe-description": "Créer une nouvelle recette de zéro.",
|
||||||
"create-recipes": "Créer des recettes",
|
"create-recipes": "Créer des recettes",
|
||||||
"import-with-zip": "Importer un .zip",
|
"import-with-zip": "Importer un .zip",
|
||||||
"create-recipe-from-an-image": "Créer une recette à partir d’une image",
|
"create-recipe-from-images": "Créer une recette depuis une image",
|
||||||
"create-recipe-from-an-image-description": "Créez une recette en téléchargeant une image de celle-ci. Mealie utilisera l’IA pour tenter d’extraire le texte et de créer une recette.",
|
"create-recipe-from-an-image-description": "Créez une recette en téléchargeant une image de celle-ci. Mealie utilisera l’IA pour tenter d’extraire le texte et de créer une recette.",
|
||||||
"crop-and-rotate-the-image": "Rogner et pivoter l’image pour que seul le texte soit visible, et qu’il soit dans la bonne orientation.",
|
"crop-and-rotate-the-image": "Rogner et pivoter l’image pour que seul le texte soit visible, et qu’il soit dans la bonne orientation.",
|
||||||
"create-from-images": "Créer à partir d’images",
|
"create-from-images": "Créer à partir d’images",
|
||||||
@@ -943,7 +943,7 @@
|
|||||||
"are-you-sure-you-want-to-uncheck-all-items": "Voulez-vous vraiment désélectionner tous les éléments ?",
|
"are-you-sure-you-want-to-uncheck-all-items": "Voulez-vous vraiment désélectionner tous les éléments ?",
|
||||||
"are-you-sure-you-want-to-delete-checked-items": "Voulez-vous vraiment supprimer tous les éléments sélectionnés ?",
|
"are-you-sure-you-want-to-delete-checked-items": "Voulez-vous vraiment supprimer tous les éléments sélectionnés ?",
|
||||||
"no-shopping-lists-found": "Aucune liste de courses trouvée",
|
"no-shopping-lists-found": "Aucune liste de courses trouvée",
|
||||||
"item-checked-off": "Checked off {item}"
|
"item-checked-off": "{item} coché"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"all-recipes": "Recettes",
|
"all-recipes": "Recettes",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Crear unha receita en branco.",
|
"create-recipe-description": "Crear unha receita en branco.",
|
||||||
"create-recipes": "Crear Receitas",
|
"create-recipes": "Crear Receitas",
|
||||||
"import-with-zip": "Importar con .zip",
|
"import-with-zip": "Importar con .zip",
|
||||||
"create-recipe-from-an-image": "Crear receita a partir dunha imaxen",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Cree unha receita cargando unha imaxen da mesma. O Mealie tentará extrair o texto da imaxen utilizando IA e creará unha receita a partir da mesma.",
|
"create-recipe-from-an-image-description": "Cree unha receita cargando unha imaxen da mesma. O Mealie tentará extrair o texto da imaxen utilizando IA e creará unha receita a partir da mesma.",
|
||||||
"crop-and-rotate-the-image": "Recorte e vire a imaxen de modo a que só o texto sexa visível e na orientación correta.",
|
"crop-and-rotate-the-image": "Recorte e vire a imaxen de modo a que só o texto sexa visível e na orientación correta.",
|
||||||
"create-from-images": "Crear a partir de imaxens",
|
"create-from-images": "Crear a partir de imaxens",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "יצירת מתכון חדש מאפס.",
|
"create-recipe-description": "יצירת מתכון חדש מאפס.",
|
||||||
"create-recipes": "יצירת מתכונים",
|
"create-recipes": "יצירת מתכונים",
|
||||||
"import-with-zip": "ייבא באמצעות zip",
|
"import-with-zip": "ייבא באמצעות zip",
|
||||||
"create-recipe-from-an-image": "יצירת מתכון מתמונה",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "יצירת מתכון ע\"י העלאת תמונה שלו. Mealie תנסה לחלץ את הטקסט מהתמונה באמצעות AI ותייצר ממנו מתכון.",
|
"create-recipe-from-an-image-description": "יצירת מתכון ע\"י העלאת תמונה שלו. Mealie תנסה לחלץ את הטקסט מהתמונה באמצעות AI ותייצר ממנו מתכון.",
|
||||||
"crop-and-rotate-the-image": "נא לחתוך ולסובב את התמונה כך שרואים רק את הטקסט, והוא בכיוון הנכון.",
|
"crop-and-rotate-the-image": "נא לחתוך ולסובב את התמונה כך שרואים רק את הטקסט, והוא בכיוון הנכון.",
|
||||||
"create-from-images": "יצירה מתמונה",
|
"create-from-images": "יצירה מתמונה",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Izradi novi recept od početka",
|
"create-recipe-description": "Izradi novi recept od početka",
|
||||||
"create-recipes": "Kreiraj recept",
|
"create-recipes": "Kreiraj recept",
|
||||||
"import-with-zip": "Učitaj pomoću .zip-a",
|
"import-with-zip": "Učitaj pomoću .zip-a",
|
||||||
"create-recipe-from-an-image": "Create Recipe from an Image",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
||||||
"crop-and-rotate-the-image": "Obreži i rotiraj sliku tako da bude vidljiv samo tekst i da bude u ispravnoj orijentaciji.",
|
"crop-and-rotate-the-image": "Obreži i rotiraj sliku tako da bude vidljiv samo tekst i da bude u ispravnoj orijentaciji.",
|
||||||
"create-from-images": "Izradi na temelju fotografije",
|
"create-from-images": "Izradi na temelju fotografije",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Adj hozzá egy új receptet a nulláról kezdve.",
|
"create-recipe-description": "Adj hozzá egy új receptet a nulláról kezdve.",
|
||||||
"create-recipes": "Receptek létrehozása",
|
"create-recipes": "Receptek létrehozása",
|
||||||
"import-with-zip": "Importálás .zip formátummal",
|
"import-with-zip": "Importálás .zip formátummal",
|
||||||
"create-recipe-from-an-image": "Recept készítése képről",
|
"create-recipe-from-images": "Recept létrehozása képek alapján",
|
||||||
"create-recipe-from-an-image-description": "Hozzon létre egy receptet egy kép feltöltésével. A Mealie megpróbálja a kép szövegét mesterséges intelligencia segítségével kinyerni, és létrehozni belőle a receptet.",
|
"create-recipe-from-an-image-description": "Hozzon létre egy receptet egy kép feltöltésével. A Mealie megpróbálja a kép szövegét mesterséges intelligencia segítségével kinyerni, és létrehozni belőle a receptet.",
|
||||||
"crop-and-rotate-the-image": "Vágja ki és forgassa el a képet úgy, hogy csak a szöveg legyen látható, és megfelelő tájolásban legyen.",
|
"crop-and-rotate-the-image": "Vágja ki és forgassa el a képet úgy, hogy csak a szöveg legyen látható, és megfelelő tájolásban legyen.",
|
||||||
"create-from-images": "Létrehozás képekről",
|
"create-from-images": "Létrehozás képekről",
|
||||||
@@ -943,7 +943,7 @@
|
|||||||
"are-you-sure-you-want-to-uncheck-all-items": "Biztos, hogy minden elem kijelölését visszavonja?",
|
"are-you-sure-you-want-to-uncheck-all-items": "Biztos, hogy minden elem kijelölését visszavonja?",
|
||||||
"are-you-sure-you-want-to-delete-checked-items": "Biztosan törölni akarja az összes bejelölt elemet?",
|
"are-you-sure-you-want-to-delete-checked-items": "Biztosan törölni akarja az összes bejelölt elemet?",
|
||||||
"no-shopping-lists-found": "Nem találhatók bevásárlólisták",
|
"no-shopping-lists-found": "Nem találhatók bevásárlólisták",
|
||||||
"item-checked-off": "Checked off {item}"
|
"item-checked-off": "{item} leellenőrzve"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"all-recipes": "Minden recept",
|
"all-recipes": "Minden recept",
|
||||||
|
|||||||
@@ -333,8 +333,8 @@
|
|||||||
"any-household": "Öll heimili",
|
"any-household": "Öll heimili",
|
||||||
"no-meal-plan-defined-yet": "Ekkert matarplan hefur verið skilgreint",
|
"no-meal-plan-defined-yet": "Ekkert matarplan hefur verið skilgreint",
|
||||||
"no-meal-planned-for-today": "Ekkert matarplan skipulagt í dag",
|
"no-meal-planned-for-today": "Ekkert matarplan skipulagt í dag",
|
||||||
"numberOfDaysPast-hint": "Number of days in the past on page load",
|
"numberOfDaysPast-hint": "Fjöldi liðina daga við síðuhleðslu",
|
||||||
"numberOfDaysPast-label": "Default Days in the Past",
|
"numberOfDaysPast-label": "Sjálfgefnir liðnir dagar",
|
||||||
"numberOfDays-hint": "Fjöldi daga við síðuhleðslu",
|
"numberOfDays-hint": "Fjöldi daga við síðuhleðslu",
|
||||||
"numberOfDays-label": "Sjálfgefnir dagar",
|
"numberOfDays-label": "Sjálfgefnir dagar",
|
||||||
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Aðeins uppskriftir í þessum flokkum verða notaðir í matarplan",
|
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Aðeins uppskriftir í þessum flokkum verða notaðir í matarplan",
|
||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Stofna nýja uppskrift frá grunni.",
|
"create-recipe-description": "Stofna nýja uppskrift frá grunni.",
|
||||||
"create-recipes": "Stofna uppskriftir",
|
"create-recipes": "Stofna uppskriftir",
|
||||||
"import-with-zip": "Hlaða inn með .zip",
|
"import-with-zip": "Hlaða inn með .zip",
|
||||||
"create-recipe-from-an-image": "Stofna uppskrift út frá mynd",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Stofna uppskrift með því hlaða inn myndum af uppskriftartextanum. Mealie mun reyna að vinna texta úr myndunum með gervigreind og stofna nýja uppskrift út frá textanum.",
|
"create-recipe-from-an-image-description": "Stofna uppskrift með því hlaða inn myndum af uppskriftartextanum. Mealie mun reyna að vinna texta úr myndunum með gervigreind og stofna nýja uppskrift út frá textanum.",
|
||||||
"crop-and-rotate-the-image": "Sníða og snúa mynd svo bara textinn sé sýnilegur og að myndin snúi rétt.",
|
"crop-and-rotate-the-image": "Sníða og snúa mynd svo bara textinn sé sýnilegur og að myndin snúi rétt.",
|
||||||
"create-from-images": "Stofna uppskrift frá mynd",
|
"create-from-images": "Stofna uppskrift frá mynd",
|
||||||
@@ -640,8 +640,8 @@
|
|||||||
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Stofnaðu uppskrift með því að gefa henni nafn, allar uppskriftir þurfa að hafa einstakt nafn.",
|
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Stofnaðu uppskrift með því að gefa henni nafn, allar uppskriftir þurfa að hafa einstakt nafn.",
|
||||||
"new-recipe-names-must-be-unique": "Nöfn uppskrifta þurfa að vera einstök",
|
"new-recipe-names-must-be-unique": "Nöfn uppskrifta þurfa að vera einstök",
|
||||||
"scrape-recipe": "Vinna uppskrift",
|
"scrape-recipe": "Vinna uppskrift",
|
||||||
"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": "Sækja uppskrift af vefslóð. Settu inn vefslóð fyrir síðuna þar sem þú vilt sækja uppskrift og Mealie mun reyna að vinna uppskriftina þaðan og bæta henni við safnið þitt.",
|
||||||
"scrape-recipe-description-transcription": "You can also provide the url to a video and Mealie will attempt to transcribe it into a recipe.",
|
"scrape-recipe-description-transcription": "Þú getur einnig sett inn slóð á video og Mealie mun reyna að umrita það yfir í uppskrift.",
|
||||||
"scrape-recipe-have-a-lot-of-recipes": "Ertu með margar uppskriftir sem þú villt setja inn í einu?",
|
"scrape-recipe-have-a-lot-of-recipes": "Ertu með margar uppskriftir sem þú villt setja inn í einu?",
|
||||||
"scrape-recipe-suggest-bulk-importer": "Prófaðu að setja inn margar uppskriftir í einu",
|
"scrape-recipe-suggest-bulk-importer": "Prófaðu að setja inn margar uppskriftir í einu",
|
||||||
"scrape-recipe-have-raw-html-or-json-data": "Ertu með hrá HTML eða JSON gögn?",
|
"scrape-recipe-have-raw-html-or-json-data": "Ertu með hrá HTML eða JSON gögn?",
|
||||||
@@ -893,17 +893,17 @@
|
|||||||
"server-side-base-url-error-text": "'BASE_URL' er enn sjálfgefið gildi á API netþjóns. Þetta getur valdið vandræðum með tilkynninga tengla sem netþjónninn býr til fyrir tölvupósta og annað.",
|
"server-side-base-url-error-text": "'BASE_URL' er enn sjálfgefið gildi á API netþjóns. Þetta getur valdið vandræðum með tilkynninga tengla sem netþjónninn býr til fyrir tölvupósta og annað.",
|
||||||
"server-side-base-url-success-text": "Slóð netþjóns samsvarar ekki sjálfgefnu gildi",
|
"server-side-base-url-success-text": "Slóð netþjóns samsvarar ekki sjálfgefnu gildi",
|
||||||
"ldap-ready": "LDAP klár",
|
"ldap-ready": "LDAP klár",
|
||||||
"ldap-not-ready": "LDAP Not Ready",
|
"ldap-not-ready": "LDAP er ekki tilbúið",
|
||||||
"ldap-ready-error-text": "Ekki öll LDAP-gildi eru stillt. Þetta má hunsa ef þú notar ekki LDAP-auðkenningu.",
|
"ldap-ready-error-text": "Ekki öll LDAP-gildi eru stillt. Þetta má hunsa ef þú notar ekki LDAP-auðkenningu.",
|
||||||
"ldap-ready-success-text": "Öll nauðsynleg LDAP-gildi eru stillt.",
|
"ldap-ready-success-text": "Öll nauðsynleg LDAP-gildi eru stillt.",
|
||||||
"build": "Build",
|
"build": "Build",
|
||||||
"recipe-scraper-version": "Recipe Scraper útgáfa",
|
"recipe-scraper-version": "Recipe Scraper útgáfa",
|
||||||
"oidc-ready": "OIDC klár",
|
"oidc-ready": "OIDC klár",
|
||||||
"oidc-not-ready": "OIDC Not Ready",
|
"oidc-not-ready": "OIDC er ekki tilbúið",
|
||||||
"oidc-ready-error-text": "Ekki öll OIDC gildi eru stillt. Þetta má hunsa ef þú notar ekki OIDC-auðkenningu.",
|
"oidc-ready-error-text": "Ekki öll OIDC gildi eru stillt. Þetta má hunsa ef þú notar ekki OIDC-auðkenningu.",
|
||||||
"oidc-ready-success-text": "Öll nauðsynleg OIDC-gildi eru stillt.",
|
"oidc-ready-success-text": "Öll nauðsynleg OIDC-gildi eru stillt.",
|
||||||
"openai-ready": "OpenAI klár",
|
"openai-ready": "OpenAI klár",
|
||||||
"openai-not-ready": "OpenAI Not Ready",
|
"openai-not-ready": "OpenAI er ekki tilbúið",
|
||||||
"openai-ready-error-text": "Ekki öll OpenAI gildi eru stillt. Þetta má hunsa ef þú notar ekki OpenAI.",
|
"openai-ready-error-text": "Ekki öll OpenAI gildi eru stillt. Þetta má hunsa ef þú notar ekki OpenAI.",
|
||||||
"openai-ready-success-text": "Öll nauðsynleg OpenAI-gildi eru stillt."
|
"openai-ready-success-text": "Öll nauðsynleg OpenAI-gildi eru stillt."
|
||||||
},
|
},
|
||||||
@@ -917,7 +917,7 @@
|
|||||||
"quantity": "Fjöldi: {0}",
|
"quantity": "Fjöldi: {0}",
|
||||||
"shopping-list": "Innkaupalisti",
|
"shopping-list": "Innkaupalisti",
|
||||||
"shopping-lists": "Innkaupalistar",
|
"shopping-lists": "Innkaupalistar",
|
||||||
"add-item": "Add item",
|
"add-item": "Bæta við vöru",
|
||||||
"food": "Matvara",
|
"food": "Matvara",
|
||||||
"note": "Minnispunktur",
|
"note": "Minnispunktur",
|
||||||
"label": "Merkimiði",
|
"label": "Merkimiði",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Crea una nuova ricetta da zero.",
|
"create-recipe-description": "Crea una nuova ricetta da zero.",
|
||||||
"create-recipes": "Crea Ricette",
|
"create-recipes": "Crea Ricette",
|
||||||
"import-with-zip": "Importa da .zip",
|
"import-with-zip": "Importa da .zip",
|
||||||
"create-recipe-from-an-image": "Crea ricetta da un'immagine",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Crea una ricetta caricando un'immagine di essa. Mealie tenterà di estrarre il testo dall'immagine usando l'IA e creare una ricetta da esso.",
|
"create-recipe-from-an-image-description": "Crea una ricetta caricando un'immagine di essa. Mealie tenterà di estrarre il testo dall'immagine usando l'IA e creare una ricetta da esso.",
|
||||||
"crop-and-rotate-the-image": "Ritaglia e ruota l'immagine in modo che solo il testo sia visibile e che sia orientato correttamente.",
|
"crop-and-rotate-the-image": "Ritaglia e ruota l'immagine in modo che solo il testo sia visibile e che sia orientato correttamente.",
|
||||||
"create-from-images": "Crea da immagini",
|
"create-from-images": "Crea da immagini",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "新しいレシピを一から作成します。",
|
"create-recipe-description": "新しいレシピを一から作成します。",
|
||||||
"create-recipes": "レシピを作成する",
|
"create-recipes": "レシピを作成する",
|
||||||
"import-with-zip": ".zip でインポート",
|
"import-with-zip": ".zip でインポート",
|
||||||
"create-recipe-from-an-image": "画像からレシピを作成",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "画像をアップロードしてレシピを作成します。 Mealieは、AIを使用して画像からテキストを抽出し、そこからレシピを作成しようとします。",
|
"create-recipe-from-an-image-description": "画像をアップロードしてレシピを作成します。 Mealieは、AIを使用して画像からテキストを抽出し、そこからレシピを作成しようとします。",
|
||||||
"crop-and-rotate-the-image": "テキストのみが表示され、正しい方向になるように画像をトリミングして回転します。",
|
"crop-and-rotate-the-image": "テキストのみが表示され、正しい方向になるように画像をトリミングして回転します。",
|
||||||
"create-from-images": "Create from Images",
|
"create-from-images": "Create from Images",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "처음부터 새로운 레시피를 만드세요.",
|
"create-recipe-description": "처음부터 새로운 레시피를 만드세요.",
|
||||||
"create-recipes": "레시피 생성",
|
"create-recipes": "레시피 생성",
|
||||||
"import-with-zip": ".zip 파일로 가져오기",
|
"import-with-zip": ".zip 파일로 가져오기",
|
||||||
"create-recipe-from-an-image": "이미지에서 레시피 생성",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "레시피 텍스트 이미지를 업로드하여 레시피를 생성하세요. Mealie는 AI를 사용하여 이미지에서 텍스트를 추출하고 이를 통해 새로운 레시피를 생성하려고 시도합니다.",
|
"create-recipe-from-an-image-description": "레시피 텍스트 이미지를 업로드하여 레시피를 생성하세요. Mealie는 AI를 사용하여 이미지에서 텍스트를 추출하고 이를 통해 새로운 레시피를 생성하려고 시도합니다.",
|
||||||
"crop-and-rotate-the-image": "이미지를 잘라내고 회전시켜 텍스트만 보이도록 하고 올바른 방향으로 배치하십시오.",
|
"crop-and-rotate-the-image": "이미지를 잘라내고 회전시켜 텍스트만 보이도록 하고 올바른 방향으로 배치하십시오.",
|
||||||
"create-from-images": "이미지에서 생성",
|
"create-from-images": "이미지에서 생성",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Create a new recipe from scratch.",
|
"create-recipe-description": "Create a new recipe from scratch.",
|
||||||
"create-recipes": "Create Recipes",
|
"create-recipes": "Create Recipes",
|
||||||
"import-with-zip": "Įkelti naudojant .zip failus",
|
"import-with-zip": "Įkelti naudojant .zip failus",
|
||||||
"create-recipe-from-an-image": "Create Recipe from an Image",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
||||||
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
|
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
|
||||||
"create-from-images": "Kurti iš vaizdų",
|
"create-from-images": "Kurti iš vaizdų",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Izveidojiet jaunu recepti no nulles.",
|
"create-recipe-description": "Izveidojiet jaunu recepti no nulles.",
|
||||||
"create-recipes": "Izveidojiet receptes",
|
"create-recipes": "Izveidojiet receptes",
|
||||||
"import-with-zip": "Importēt ar .zip",
|
"import-with-zip": "Importēt ar .zip",
|
||||||
"create-recipe-from-an-image": "Izveidojiet recepti no attēla",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Izveidojiet recepti, augšupielādējot tās attēlu. Mealie mēģinās iegūt tekstu no attēla, izmantojot AI, un no tā izveidot recepti.",
|
"create-recipe-from-an-image-description": "Izveidojiet recepti, augšupielādējot tās attēlu. Mealie mēģinās iegūt tekstu no attēla, izmantojot AI, un no tā izveidot recepti.",
|
||||||
"crop-and-rotate-the-image": "Apgrieziet un pagrieziet attēlu tā, lai būtu redzams tikai teksts un tas būtu pareizajā orientācijā.",
|
"crop-and-rotate-the-image": "Apgrieziet un pagrieziet attēlu tā, lai būtu redzams tikai teksts un tas būtu pareizajā orientācijā.",
|
||||||
"create-from-images": "Create from Images",
|
"create-from-images": "Create from Images",
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
"category": "Categorie"
|
"category": "Categorie"
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"apprise-url": "Apprise URL",
|
"apprise-url": "Kennisgevings-url",
|
||||||
"database": "Database",
|
"database": "Database",
|
||||||
"delete-event": "Gebeurtenis verwijderen",
|
"delete-event": "Gebeurtenis verwijderen",
|
||||||
"event-delete-confirmation": "Weet je zeker dat je deze gebeurtenis wilt verwijderen?",
|
"event-delete-confirmation": "Weet je zeker dat je deze gebeurtenis wilt verwijderen?",
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"delete": "Verwijderen",
|
"delete": "Verwijderen",
|
||||||
"disabled": "Uitgeschakeld",
|
"disabled": "Uitgeschakeld",
|
||||||
"done": "Done",
|
"done": "Gereed",
|
||||||
"download": "Downloaden",
|
"download": "Downloaden",
|
||||||
"duplicate": "Dupliceren",
|
"duplicate": "Dupliceren",
|
||||||
"edit": "Bewerken",
|
"edit": "Bewerken",
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
"token": "Token",
|
"token": "Token",
|
||||||
"tuesday": "dinsdag",
|
"tuesday": "dinsdag",
|
||||||
"type": "Soort",
|
"type": "Soort",
|
||||||
"undo": "Undo",
|
"undo": "Ongedaan maken",
|
||||||
"update": "Bijwerken",
|
"update": "Bijwerken",
|
||||||
"updated": "Bijgewerkt",
|
"updated": "Bijgewerkt",
|
||||||
"upload": "Uploaden",
|
"upload": "Uploaden",
|
||||||
@@ -333,8 +333,8 @@
|
|||||||
"any-household": "Elk huishouden",
|
"any-household": "Elk huishouden",
|
||||||
"no-meal-plan-defined-yet": "Nog geen maaltijdplan opgesteld",
|
"no-meal-plan-defined-yet": "Nog geen maaltijdplan opgesteld",
|
||||||
"no-meal-planned-for-today": "Geen maaltijd gepland voor vandaag",
|
"no-meal-planned-for-today": "Geen maaltijd gepland voor vandaag",
|
||||||
"numberOfDaysPast-hint": "Number of days in the past on page load",
|
"numberOfDaysPast-hint": "Aantal dagen in het verleden bij laden pagina",
|
||||||
"numberOfDaysPast-label": "Default Days in the Past",
|
"numberOfDaysPast-label": "Standaard dagen in het verleden",
|
||||||
"numberOfDays-hint": "Aantal dagen bij laden van de pagina",
|
"numberOfDays-hint": "Aantal dagen bij laden van de pagina",
|
||||||
"numberOfDays-label": "Standaard aantal dagen",
|
"numberOfDays-label": "Standaard aantal dagen",
|
||||||
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Alleen recepten met deze categorieën zullen worden gebruikt in maaltijdplannen",
|
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Alleen recepten met deze categorieën zullen worden gebruikt in maaltijdplannen",
|
||||||
@@ -443,7 +443,7 @@
|
|||||||
"error-details": "Alleen websites met ld+json of microdata kunnen worden geïmporteerd door Mealie. De meeste grote receptenwebsites ondersteunen deze gegevensstructuur. Als je site niet kan worden geïmporteerd, maar er zijn json-gegevens in de log, maak dan een github issue aan met de URL en gegevens.",
|
"error-details": "Alleen websites met ld+json of microdata kunnen worden geïmporteerd door Mealie. De meeste grote receptenwebsites ondersteunen deze gegevensstructuur. Als je site niet kan worden geïmporteerd, maar er zijn json-gegevens in de log, maak dan een github issue aan met de URL en gegevens.",
|
||||||
"error-title": "Het lijkt erop dat we niets konden vinden",
|
"error-title": "Het lijkt erop dat we niets konden vinden",
|
||||||
"from-url": "Recept importeren",
|
"from-url": "Recept importeren",
|
||||||
"github-issues": "GitHub Issues",
|
"github-issues": "GitHubproblemen",
|
||||||
"google-ld-json-info": "Google ld+json Info",
|
"google-ld-json-info": "Google ld+json Info",
|
||||||
"must-be-a-valid-url": "Moet een geldige URL zijn",
|
"must-be-a-valid-url": "Moet een geldige URL zijn",
|
||||||
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Plak je receptgegevens. Elke regel wordt behandeld als een item in een lijst",
|
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Plak je receptgegevens. Elke regel wordt behandeld als een item in een lijst",
|
||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Maak een nieuw recept.",
|
"create-recipe-description": "Maak een nieuw recept.",
|
||||||
"create-recipes": "Recepten aanmaken",
|
"create-recipes": "Recepten aanmaken",
|
||||||
"import-with-zip": "Importeer met .zip",
|
"import-with-zip": "Importeer met .zip",
|
||||||
"create-recipe-from-an-image": "Maak recept van de tekst op een afbeelding",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Maak een recept door een afbeelding ervan te uploaden. Mealie probeert de tekst met behulp van AI uit de afbeelding te halen en er een recept uit te maken.",
|
"create-recipe-from-an-image-description": "Maak een recept door een afbeelding ervan te uploaden. Mealie probeert de tekst met behulp van AI uit de afbeelding te halen en er een recept uit te maken.",
|
||||||
"crop-and-rotate-the-image": "Snijd de afbeelding bij zodat alleen tekst zichtbaar is. En draai t plaatje zodat het leesbaar is.",
|
"crop-and-rotate-the-image": "Snijd de afbeelding bij zodat alleen tekst zichtbaar is. En draai t plaatje zodat het leesbaar is.",
|
||||||
"create-from-images": "Maak recept van een afbeelding",
|
"create-from-images": "Maak recept van een afbeelding",
|
||||||
@@ -893,17 +893,17 @@
|
|||||||
"server-side-base-url-error-text": "`BASE_URL` is nog steeds de standaard waarde op de API Server. Dit geeft problemen met notificatielinks in e-mails etc.",
|
"server-side-base-url-error-text": "`BASE_URL` is nog steeds de standaard waarde op de API Server. Dit geeft problemen met notificatielinks in e-mails etc.",
|
||||||
"server-side-base-url-success-text": "Server-side URL komt niet overeen met de standaard",
|
"server-side-base-url-success-text": "Server-side URL komt niet overeen met de standaard",
|
||||||
"ldap-ready": "LDAP klaar",
|
"ldap-ready": "LDAP klaar",
|
||||||
"ldap-not-ready": "LDAP Not Ready",
|
"ldap-not-ready": "LDAP niet gereed",
|
||||||
"ldap-ready-error-text": "Niet alle LDAP-waarden zijn geconfigureerd. Dit kan worden genegeerd als je geen LDAP-authenticatie gebruikt.",
|
"ldap-ready-error-text": "Niet alle LDAP-waarden zijn geconfigureerd. Dit kan worden genegeerd als je geen LDAP-authenticatie gebruikt.",
|
||||||
"ldap-ready-success-text": "Vereiste LDAP variabelen zijn helemaal ingesteld.",
|
"ldap-ready-success-text": "Vereiste LDAP variabelen zijn helemaal ingesteld.",
|
||||||
"build": "Build",
|
"build": "Build",
|
||||||
"recipe-scraper-version": "Versie van de receptenscraper",
|
"recipe-scraper-version": "Versie van de receptenscraper",
|
||||||
"oidc-ready": "OIDC klaar",
|
"oidc-ready": "OIDC klaar",
|
||||||
"oidc-not-ready": "OIDC Not Ready",
|
"oidc-not-ready": "OIDC niet gereed",
|
||||||
"oidc-ready-error-text": "Niet alle OIDC-waarden zijn geconfigureerd. Dit kan worden genegeerd als je geen OIDC-authenticatie gebruikt.",
|
"oidc-ready-error-text": "Niet alle OIDC-waarden zijn geconfigureerd. Dit kan worden genegeerd als je geen OIDC-authenticatie gebruikt.",
|
||||||
"oidc-ready-success-text": "Vereiste OIDC-variabelen zijn allemaal ingesteld.",
|
"oidc-ready-success-text": "Vereiste OIDC-variabelen zijn allemaal ingesteld.",
|
||||||
"openai-ready": "OpenAI staat klaar",
|
"openai-ready": "OpenAI staat klaar",
|
||||||
"openai-not-ready": "OpenAI Not Ready",
|
"openai-not-ready": "OpenAI niet gereed",
|
||||||
"openai-ready-error-text": "Niet alle tekstvakken voor OpenAI zijn ingevuld. Als je geen OpenAI gebruikt kun je dit leeg laten.",
|
"openai-ready-error-text": "Niet alle tekstvakken voor OpenAI zijn ingevuld. Als je geen OpenAI gebruikt kun je dit leeg laten.",
|
||||||
"openai-ready-success-text": "Verplichte tekstvakken voor OpenAI zijn ingevuld."
|
"openai-ready-success-text": "Verplichte tekstvakken voor OpenAI zijn ingevuld."
|
||||||
},
|
},
|
||||||
@@ -917,7 +917,7 @@
|
|||||||
"quantity": "Hoeveelheid: {0}",
|
"quantity": "Hoeveelheid: {0}",
|
||||||
"shopping-list": "Boodschappenlijst",
|
"shopping-list": "Boodschappenlijst",
|
||||||
"shopping-lists": "Boodschappenlijsten",
|
"shopping-lists": "Boodschappenlijsten",
|
||||||
"add-item": "Add item",
|
"add-item": "Item toevoegen",
|
||||||
"food": "Levensmiddelen",
|
"food": "Levensmiddelen",
|
||||||
"note": "Notitie",
|
"note": "Notitie",
|
||||||
"label": "Label",
|
"label": "Label",
|
||||||
@@ -943,7 +943,7 @@
|
|||||||
"are-you-sure-you-want-to-uncheck-all-items": "Weet je zeker dat je alle items wilt deselecteren?",
|
"are-you-sure-you-want-to-uncheck-all-items": "Weet je zeker dat je alle items wilt deselecteren?",
|
||||||
"are-you-sure-you-want-to-delete-checked-items": "Weet je zeker dat je de geselecteerde items wilt verwijderen?",
|
"are-you-sure-you-want-to-delete-checked-items": "Weet je zeker dat je de geselecteerde items wilt verwijderen?",
|
||||||
"no-shopping-lists-found": "Geen boodschappenlijsten gevonden",
|
"no-shopping-lists-found": "Geen boodschappenlijsten gevonden",
|
||||||
"item-checked-off": "Checked off {item}"
|
"item-checked-off": "Uitgevinkt {item}"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"all-recipes": "Alle Recepten",
|
"all-recipes": "Alle Recepten",
|
||||||
@@ -1283,7 +1283,7 @@
|
|||||||
"split-by-block": "Splits per tekstblok",
|
"split-by-block": "Splits per tekstblok",
|
||||||
"flatten": "Plat maken ongeacht originele opmaak",
|
"flatten": "Plat maken ongeacht originele opmaak",
|
||||||
"help": {
|
"help": {
|
||||||
"help": "Help",
|
"help": "Hulp",
|
||||||
"mouse-modes": "Muismodus",
|
"mouse-modes": "Muismodus",
|
||||||
"selection-mode": "Selectiemodus (standaard)",
|
"selection-mode": "Selectiemodus (standaard)",
|
||||||
"selection-mode-desc": "De selectiemodus is de hoofdmodus die gebruikt kan worden om gegevens in te voeren:",
|
"selection-mode-desc": "De selectiemodus is de hoofdmodus die gebruikt kan worden om gegevens in te voeren:",
|
||||||
@@ -1478,10 +1478,10 @@
|
|||||||
"max-length": "Moet maximaal {max} tekens bevatten|Moet maximaal {max} tekens bevatten"
|
"max-length": "Moet maximaal {max} tekens bevatten|Moet maximaal {max} tekens bevatten"
|
||||||
},
|
},
|
||||||
"announcements": {
|
"announcements": {
|
||||||
"announcements": "Announcements",
|
"announcements": "Aankondigingen",
|
||||||
"all-announcements": "All announcements",
|
"all-announcements": "Alle aankondigingen",
|
||||||
"mark-all-as-read": "Mark All as Read",
|
"mark-all-as-read": "Alles markeren als gelezen",
|
||||||
"show-announcements-from-mealie": "Show announcements from Mealie",
|
"show-announcements-from-mealie": "Aankondigingen van Mealie weergeven",
|
||||||
"show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings"
|
"show-announcements-setting-description": "Of je gebruikers wel of niet meldingen van Mealie wilt laten zien. Wanneer ingeschakeld kunnen gebruikers nog steeds afzien van het bekijken van hen in hun gebruikersinstellingen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
"dashboard": "Kontrollpanel",
|
"dashboard": "Kontrollpanel",
|
||||||
"delete": "Slett",
|
"delete": "Slett",
|
||||||
"disabled": "Deaktivert",
|
"disabled": "Deaktivert",
|
||||||
"done": "Done",
|
"done": "Ferdig",
|
||||||
"download": "Last ned",
|
"download": "Last ned",
|
||||||
"duplicate": "Dupliser",
|
"duplicate": "Dupliser",
|
||||||
"edit": "Rediger",
|
"edit": "Rediger",
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
"token": "Token",
|
"token": "Token",
|
||||||
"tuesday": "Tirsdag",
|
"tuesday": "Tirsdag",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"undo": "Undo",
|
"undo": "Angre",
|
||||||
"update": "Oppdater",
|
"update": "Oppdater",
|
||||||
"updated": "Oppdatert",
|
"updated": "Oppdatert",
|
||||||
"upload": "Last opp",
|
"upload": "Last opp",
|
||||||
@@ -334,7 +334,7 @@
|
|||||||
"no-meal-plan-defined-yet": "Ingen måltidsplan er definert ennå",
|
"no-meal-plan-defined-yet": "Ingen måltidsplan er definert ennå",
|
||||||
"no-meal-planned-for-today": "Ingen måltid planlagt i dag",
|
"no-meal-planned-for-today": "Ingen måltid planlagt i dag",
|
||||||
"numberOfDaysPast-hint": "Number of days in the past on page load",
|
"numberOfDaysPast-hint": "Number of days in the past on page load",
|
||||||
"numberOfDaysPast-label": "Default Days in the Past",
|
"numberOfDaysPast-label": "Standard antall dager tilbake",
|
||||||
"numberOfDays-hint": "Antall dager på sideinnlasting",
|
"numberOfDays-hint": "Antall dager på sideinnlasting",
|
||||||
"numberOfDays-label": "Standard antall dager",
|
"numberOfDays-label": "Standard antall dager",
|
||||||
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Kun oppskrifter med disse kategoriene vil bli brukt i måltidsplaner",
|
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Kun oppskrifter med disse kategoriene vil bli brukt i måltidsplaner",
|
||||||
@@ -392,7 +392,7 @@
|
|||||||
"nextcloud": {
|
"nextcloud": {
|
||||||
"description": "Overfør data fra en Nextcloud Cookbook-instans",
|
"description": "Overfør data fra en Nextcloud Cookbook-instans",
|
||||||
"description-long": "Oppskrifter fra Nextcloud kan importeres fra en zip-fil som inneholder dataene lagret i Nextcloud. Se eksempelet på mappestrukture nedenfor for å sikre at oppskriftene kan importeres.",
|
"description-long": "Oppskrifter fra Nextcloud kan importeres fra en zip-fil som inneholder dataene lagret i Nextcloud. Se eksempelet på mappestrukture nedenfor for å sikre at oppskriftene kan importeres.",
|
||||||
"title": "Nextcloud Cookbook"
|
"title": "Nextcloud kokebok"
|
||||||
},
|
},
|
||||||
"copymethat": {
|
"copymethat": {
|
||||||
"description-long": "Mealie kan importere oppskrifter fra Copy Me That. Eksporter oppskrifter i HTML-format, last deretter opp .zip-filen under.",
|
"description-long": "Mealie kan importere oppskrifter fra Copy Me That. Eksporter oppskrifter i HTML-format, last deretter opp .zip-filen under.",
|
||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Opprett en ny oppskrift fra bunnen av.",
|
"create-recipe-description": "Opprett en ny oppskrift fra bunnen av.",
|
||||||
"create-recipes": "Opprett oppskrifter",
|
"create-recipes": "Opprett oppskrifter",
|
||||||
"import-with-zip": "Importer fra .zip-fil",
|
"import-with-zip": "Importer fra .zip-fil",
|
||||||
"create-recipe-from-an-image": "Opprett oppskrift fra et bilde",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Opprett en oppskrift ved å laste opp et bilde av den. Mealie vil forsøke å hente ut teksten fra bildet ved bruk av AI, og lage en ny oppskrift.",
|
"create-recipe-from-an-image-description": "Opprett en oppskrift ved å laste opp et bilde av den. Mealie vil forsøke å hente ut teksten fra bildet ved bruk av AI, og lage en ny oppskrift.",
|
||||||
"crop-and-rotate-the-image": "Beskjær og roter bildet slik at bare teksten er synlig, og at det er i riktig retning.",
|
"crop-and-rotate-the-image": "Beskjær og roter bildet slik at bare teksten er synlig, og at det er i riktig retning.",
|
||||||
"create-from-images": "Opprett fra bilde",
|
"create-from-images": "Opprett fra bilde",
|
||||||
@@ -917,7 +917,7 @@
|
|||||||
"quantity": "Antall: {0}",
|
"quantity": "Antall: {0}",
|
||||||
"shopping-list": "Handleliste",
|
"shopping-list": "Handleliste",
|
||||||
"shopping-lists": "Handlelister",
|
"shopping-lists": "Handlelister",
|
||||||
"add-item": "Add item",
|
"add-item": "Legg til produkt",
|
||||||
"food": "Matvare",
|
"food": "Matvare",
|
||||||
"note": "Notat",
|
"note": "Notat",
|
||||||
"label": "Etikett",
|
"label": "Etikett",
|
||||||
@@ -943,7 +943,7 @@
|
|||||||
"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": "Ingen handlelister funnet",
|
"no-shopping-lists-found": "Ingen handlelister funnet",
|
||||||
"item-checked-off": "Checked off {item}"
|
"item-checked-off": "Avkrysset av {item}"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"all-recipes": "Alle oppskrifter",
|
"all-recipes": "Alle oppskrifter",
|
||||||
@@ -1478,10 +1478,10 @@
|
|||||||
"max-length": "Må være minst minst {max} tegn må bestå av maks {max} tegn"
|
"max-length": "Må være minst minst {max} tegn må bestå av maks {max} tegn"
|
||||||
},
|
},
|
||||||
"announcements": {
|
"announcements": {
|
||||||
"announcements": "Announcements",
|
"announcements": "Kunngjøringer",
|
||||||
"all-announcements": "All announcements",
|
"all-announcements": "Alle kunngjøringer",
|
||||||
"mark-all-as-read": "Mark All as Read",
|
"mark-all-as-read": "Marker alle som lest",
|
||||||
"show-announcements-from-mealie": "Show announcements from Mealie",
|
"show-announcements-from-mealie": "Vis kunngjøringer fra Mealie",
|
||||||
"show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings"
|
"show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
"category": "Kategoria"
|
"category": "Kategoria"
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"apprise-url": "Apprise URL",
|
"apprise-url": "URL Apprise",
|
||||||
"database": "Baza danych",
|
"database": "Baza danych",
|
||||||
"delete-event": "Usuń wydarzenie",
|
"delete-event": "Usuń wydarzenie",
|
||||||
"event-delete-confirmation": "Czy na pewno chcesz usunąć to zdarzenie?",
|
"event-delete-confirmation": "Czy na pewno chcesz usunąć to zdarzenie?",
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
"dashboard": "Panel główny",
|
"dashboard": "Panel główny",
|
||||||
"delete": "Usuń",
|
"delete": "Usuń",
|
||||||
"disabled": "Wyłączone",
|
"disabled": "Wyłączone",
|
||||||
"done": "Done",
|
"done": "Gotowe",
|
||||||
"download": "Pobierz",
|
"download": "Pobierz",
|
||||||
"duplicate": "Duplikuj",
|
"duplicate": "Duplikuj",
|
||||||
"edit": "Edytuj",
|
"edit": "Edytuj",
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
"token": "Token",
|
"token": "Token",
|
||||||
"tuesday": "Wtorek",
|
"tuesday": "Wtorek",
|
||||||
"type": "Typ",
|
"type": "Typ",
|
||||||
"undo": "Undo",
|
"undo": "Cofnij",
|
||||||
"update": "Zaktualizuj",
|
"update": "Zaktualizuj",
|
||||||
"updated": "Zaktualizowano",
|
"updated": "Zaktualizowano",
|
||||||
"upload": "Prześlij",
|
"upload": "Prześlij",
|
||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Utwórz nowy przepis od zera.",
|
"create-recipe-description": "Utwórz nowy przepis od zera.",
|
||||||
"create-recipes": "Utwórz przepisy",
|
"create-recipes": "Utwórz przepisy",
|
||||||
"import-with-zip": "Importuj z pliku .zip",
|
"import-with-zip": "Importuj z pliku .zip",
|
||||||
"create-recipe-from-an-image": "Utwórz przepis z obrazów",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Utwórz przepis poprzez przesłanie obrazów tekstu przepisu. Mealie spróbuje wyodrębnić tekst z obrazów za pomocą AI i utworzyć z niego przepis.",
|
"create-recipe-from-an-image-description": "Utwórz przepis poprzez przesłanie obrazów tekstu przepisu. Mealie spróbuje wyodrębnić tekst z obrazów za pomocą AI i utworzyć z niego przepis.",
|
||||||
"crop-and-rotate-the-image": "Przytnij i obróć obraz, tak aby był w odpowiedniej orientacji i był widoczny tylko tekst.",
|
"crop-and-rotate-the-image": "Przytnij i obróć obraz, tak aby był w odpowiedniej orientacji i był widoczny tylko tekst.",
|
||||||
"create-from-images": "Utwórz przepis z obrazów",
|
"create-from-images": "Utwórz przepis z obrazów",
|
||||||
@@ -917,7 +917,7 @@
|
|||||||
"quantity": "Ilość: {0}",
|
"quantity": "Ilość: {0}",
|
||||||
"shopping-list": "Lista zakupów",
|
"shopping-list": "Lista zakupów",
|
||||||
"shopping-lists": "Listy zakupów",
|
"shopping-lists": "Listy zakupów",
|
||||||
"add-item": "Add item",
|
"add-item": "Dodaj element",
|
||||||
"food": "Jedzenie",
|
"food": "Jedzenie",
|
||||||
"note": "Notatka",
|
"note": "Notatka",
|
||||||
"label": "Etykieta",
|
"label": "Etykieta",
|
||||||
@@ -943,7 +943,7 @@
|
|||||||
"are-you-sure-you-want-to-uncheck-all-items": "Czy na pewno chcesz odznaczyć wszystkie elementy?",
|
"are-you-sure-you-want-to-uncheck-all-items": "Czy na pewno chcesz odznaczyć wszystkie elementy?",
|
||||||
"are-you-sure-you-want-to-delete-checked-items": "Czy jesteś pewien, że chcesz usunąć wszystkie zaznaczone elementy?",
|
"are-you-sure-you-want-to-delete-checked-items": "Czy jesteś pewien, że chcesz usunąć wszystkie zaznaczone elementy?",
|
||||||
"no-shopping-lists-found": "Nie znaleziono list zakupów",
|
"no-shopping-lists-found": "Nie znaleziono list zakupów",
|
||||||
"item-checked-off": "Checked off {item}"
|
"item-checked-off": "Zaznaczono {item}"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"all-recipes": "Wszystkie",
|
"all-recipes": "Wszystkie",
|
||||||
@@ -1478,10 +1478,10 @@
|
|||||||
"max-length": "Może zawierać co najwyżej {max} znak|Może zawierać co najwyżej {max} znaki|Może zawierać co najwyżej {max} znaków"
|
"max-length": "Może zawierać co najwyżej {max} znak|Może zawierać co najwyżej {max} znaki|Może zawierać co najwyżej {max} znaków"
|
||||||
},
|
},
|
||||||
"announcements": {
|
"announcements": {
|
||||||
"announcements": "Announcements",
|
"announcements": "Ogłoszenia",
|
||||||
"all-announcements": "All announcements",
|
"all-announcements": "Wszystkie ogłoszenia",
|
||||||
"mark-all-as-read": "Mark All as Read",
|
"mark-all-as-read": "Oznacz wszystkie jako przeczytane",
|
||||||
"show-announcements-from-mealie": "Show announcements from Mealie",
|
"show-announcements-from-mealie": "Pokazuj ogłoszenia z Mealie",
|
||||||
"show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings"
|
"show-announcements-setting-description": "Czy chcesz by użytkownicy widzieli ogłoszenia z Mealie? Użytkownicy będą w dalszym ciągu mogli wyłączyć ogłoszenia w swoich ustawieniach użytkownika"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Criar uma receita do zero.",
|
"create-recipe-description": "Criar uma receita do zero.",
|
||||||
"create-recipes": "Criar Receitas",
|
"create-recipes": "Criar Receitas",
|
||||||
"import-with-zip": "Importar a partir de .zip",
|
"import-with-zip": "Importar a partir de .zip",
|
||||||
"create-recipe-from-an-image": "Create Recipe from an Image",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
||||||
"crop-and-rotate-the-image": "Corte e gire a imagem para que apenas o texto esteja visível e esteja na posição correta.",
|
"crop-and-rotate-the-image": "Corte e gire a imagem para que apenas o texto esteja visível e esteja na posição correta.",
|
||||||
"create-from-images": "Criar a partir de imagens",
|
"create-from-images": "Criar a partir de imagens",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Criar uma receita em branco.",
|
"create-recipe-description": "Criar uma receita em branco.",
|
||||||
"create-recipes": "Criar Receitas",
|
"create-recipes": "Criar Receitas",
|
||||||
"import-with-zip": "Importar com .zip",
|
"import-with-zip": "Importar com .zip",
|
||||||
"create-recipe-from-an-image": "Criar receita a partir de uma imagem",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Crie uma receita carregando uma imagem da mesma. O Mealie tentará extrair o texto da imagem utilizando IA e criará uma receita a partir da mesma.",
|
"create-recipe-from-an-image-description": "Crie uma receita carregando uma imagem da mesma. O Mealie tentará extrair o texto da imagem utilizando IA e criará uma receita a partir da mesma.",
|
||||||
"crop-and-rotate-the-image": "Recorte e rode a imagem de modo a que apenas o texto seja visível e esteja na orientação correta.",
|
"crop-and-rotate-the-image": "Recorte e rode a imagem de modo a que apenas o texto seja visível e esteja na orientação correta.",
|
||||||
"create-from-images": "Criar a partir de Imagens",
|
"create-from-images": "Criar a partir de Imagens",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Creează o rețetă nouă de la zero.",
|
"create-recipe-description": "Creează o rețetă nouă de la zero.",
|
||||||
"create-recipes": "Crează rețetă",
|
"create-recipes": "Crează rețetă",
|
||||||
"import-with-zip": "Importă cu .zip",
|
"import-with-zip": "Importă cu .zip",
|
||||||
"create-recipe-from-an-image": "Creează o rețetă dintr-o imagine",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Creează o rețetă prin încărcarea unei imagini a acesteia. Mealie va încerca să extragă textul din imagine folosind AI și să creeze o rețetă din el.",
|
"create-recipe-from-an-image-description": "Creează o rețetă prin încărcarea unei imagini a acesteia. Mealie va încerca să extragă textul din imagine folosind AI și să creeze o rețetă din el.",
|
||||||
"crop-and-rotate-the-image": "Decupați și rotiți imaginea astfel încât numai textul să fie vizibil, iar orientarea să fie corectă.",
|
"crop-and-rotate-the-image": "Decupați și rotiți imaginea astfel încât numai textul să fie vizibil, iar orientarea să fie corectă.",
|
||||||
"create-from-images": "Creează din Imagini",
|
"create-from-images": "Creează din Imagini",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Создать новый рецепт с нуля.",
|
"create-recipe-description": "Создать новый рецепт с нуля.",
|
||||||
"create-recipes": "Создать Рецепт",
|
"create-recipes": "Создать Рецепт",
|
||||||
"import-with-zip": "Импорт из .zip",
|
"import-with-zip": "Импорт из .zip",
|
||||||
"create-recipe-from-an-image": "Создать рецепт из изображения",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Создайте рецепт, загрузив изображение. Mealie попытается извлечь текст из изображения с помощью ИИ и создать рецепт из него.",
|
"create-recipe-from-an-image-description": "Создайте рецепт, загрузив изображение. Mealie попытается извлечь текст из изображения с помощью ИИ и создать рецепт из него.",
|
||||||
"crop-and-rotate-the-image": "Обрежьте и поверните изображение так, чтобы виден был только текст в нужной ориентации.",
|
"crop-and-rotate-the-image": "Обрежьте и поверните изображение так, чтобы виден был только текст в нужной ориентации.",
|
||||||
"create-from-images": "Создать из изображений",
|
"create-from-images": "Создать из изображений",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Vytvoriť nový recept od začiatku.",
|
"create-recipe-description": "Vytvoriť nový recept od začiatku.",
|
||||||
"create-recipes": "Vytvoriť recept",
|
"create-recipes": "Vytvoriť recept",
|
||||||
"import-with-zip": "Importovať .zip súbor",
|
"import-with-zip": "Importovať .zip súbor",
|
||||||
"create-recipe-from-an-image": "Vytvoriť recept z obrázka",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Vytvoriť recept nahraním fotografie jedla. Mealie sa pokúsi previesť obrázok na text pomocou AI a vytvorí k nemu recept.",
|
"create-recipe-from-an-image-description": "Vytvoriť recept nahraním fotografie jedla. Mealie sa pokúsi previesť obrázok na text pomocou AI a vytvorí k nemu recept.",
|
||||||
"crop-and-rotate-the-image": "Orežte a otočte obrázok tak, aby bol viditeľný iba text a aby mal správnu orientáciu.",
|
"crop-and-rotate-the-image": "Orežte a otočte obrázok tak, aby bol viditeľný iba text a aby mal správnu orientáciu.",
|
||||||
"create-from-images": "Vytvoriť z obrázka",
|
"create-from-images": "Vytvoriť z obrázka",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Ustvari nov recept.",
|
"create-recipe-description": "Ustvari nov recept.",
|
||||||
"create-recipes": "Ustvari recepte",
|
"create-recipes": "Ustvari recepte",
|
||||||
"import-with-zip": "Uvozi z .zip",
|
"import-with-zip": "Uvozi z .zip",
|
||||||
"create-recipe-from-an-image": "Ustvari recept iz slike",
|
"create-recipe-from-images": "Ustvari recept iz slik",
|
||||||
"create-recipe-from-an-image-description": "Ustvarite recept tako, da naložite njegovo sliko. Mealie bo s pomočjo umetne inteligence poskušal izluščiti besedilo iz slike in iz njega ustvariti recept.",
|
"create-recipe-from-an-image-description": "Ustvarite recept tako, da naložite njegovo sliko. Mealie bo s pomočjo umetne inteligence poskušal izluščiti besedilo iz slike in iz njega ustvariti recept.",
|
||||||
"crop-and-rotate-the-image": "Obrežite in zasukajte sliko, tako da bo vidno samo besedilo in da bo v pravilnem položaju.",
|
"crop-and-rotate-the-image": "Obrežite in zasukajte sliko, tako da bo vidno samo besedilo in da bo v pravilnem položaju.",
|
||||||
"create-from-images": "Ustvari iz slik",
|
"create-from-images": "Ustvari iz slik",
|
||||||
@@ -943,7 +943,7 @@
|
|||||||
"are-you-sure-you-want-to-uncheck-all-items": "Ali res ne želite izbrati vseh elementov?",
|
"are-you-sure-you-want-to-uncheck-all-items": "Ali res ne želite izbrati vseh elementov?",
|
||||||
"are-you-sure-you-want-to-delete-checked-items": "Ali ste prepričani, da želite izbrisati vse izbrane elemente?",
|
"are-you-sure-you-want-to-delete-checked-items": "Ali ste prepričani, da želite izbrisati vse izbrane elemente?",
|
||||||
"no-shopping-lists-found": "Ni nakupovalnih seznamov",
|
"no-shopping-lists-found": "Ni nakupovalnih seznamov",
|
||||||
"item-checked-off": "Checked off {item}"
|
"item-checked-off": "Odkljukano {item}"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"all-recipes": "Vsi recepti",
|
"all-recipes": "Vsi recepti",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Креирајте нови рецепт од нуле.",
|
"create-recipe-description": "Креирајте нови рецепт од нуле.",
|
||||||
"create-recipes": "Направите рецепте",
|
"create-recipes": "Направите рецепте",
|
||||||
"import-with-zip": "Увези помоћу .zip архиве",
|
"import-with-zip": "Увези помоћу .zip архиве",
|
||||||
"create-recipe-from-an-image": "Направи рецепт на основи слике",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Креирајте рецепт отпремањем његове слике. Mealie ће покушати да издвоји текст из слике користећи АИ и од њега направи рецепт.",
|
"create-recipe-from-an-image-description": "Креирајте рецепт отпремањем његове слике. Mealie ће покушати да издвоји текст из слике користећи АИ и од њега направи рецепт.",
|
||||||
"crop-and-rotate-the-image": "Исеците и ротирајте слику тако да је видљив само текст и да је у исправној оријентацији.",
|
"crop-and-rotate-the-image": "Исеците и ротирајте слику тако да је видљив само текст и да је у исправној оријентацији.",
|
||||||
"create-from-images": "Креирај из слика",
|
"create-from-images": "Креирај из слика",
|
||||||
|
|||||||
@@ -169,7 +169,7 @@
|
|||||||
"token": "Token",
|
"token": "Token",
|
||||||
"tuesday": "Tisdag",
|
"tuesday": "Tisdag",
|
||||||
"type": "Typ",
|
"type": "Typ",
|
||||||
"undo": "Undo",
|
"undo": "Ångra",
|
||||||
"update": "Uppdatera",
|
"update": "Uppdatera",
|
||||||
"updated": "Uppdaterad",
|
"updated": "Uppdaterad",
|
||||||
"upload": "Ladda upp",
|
"upload": "Ladda upp",
|
||||||
@@ -333,8 +333,8 @@
|
|||||||
"any-household": "Valfritt hushåll",
|
"any-household": "Valfritt hushåll",
|
||||||
"no-meal-plan-defined-yet": "Ingen måltidsplan definierad ännu",
|
"no-meal-plan-defined-yet": "Ingen måltidsplan definierad ännu",
|
||||||
"no-meal-planned-for-today": "Ingen måltidsplan för idag",
|
"no-meal-planned-for-today": "Ingen måltidsplan för idag",
|
||||||
"numberOfDaysPast-hint": "Number of days in the past on page load",
|
"numberOfDaysPast-hint": "Antal förflutna dagar vid sidhämtning",
|
||||||
"numberOfDaysPast-label": "Default Days in the Past",
|
"numberOfDaysPast-label": "Förvalda förflutna dagar",
|
||||||
"numberOfDays-hint": "Antal dagar vid sidhämtning",
|
"numberOfDays-hint": "Antal dagar vid sidhämtning",
|
||||||
"numberOfDays-label": "Förvalda dagar",
|
"numberOfDays-label": "Förvalda dagar",
|
||||||
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Endast recept med dessa kategorier kommer att användas i måltidsplaner",
|
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Endast recept med dessa kategorier kommer att användas i måltidsplaner",
|
||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Skapa nytt recept från grunden.",
|
"create-recipe-description": "Skapa nytt recept från grunden.",
|
||||||
"create-recipes": "Skapa recept",
|
"create-recipes": "Skapa recept",
|
||||||
"import-with-zip": "Importera från .zip",
|
"import-with-zip": "Importera från .zip",
|
||||||
"create-recipe-from-an-image": "Skapa recept från en bild",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Skapa ett recept genom att ladda upp en bild på det. Mealie kommer att försöka extrahera texten från bilden med hjälp av AI och skapa ett recept från det.",
|
"create-recipe-from-an-image-description": "Skapa ett recept genom att ladda upp en bild på det. Mealie kommer att försöka extrahera texten från bilden med hjälp av AI och skapa ett recept från det.",
|
||||||
"crop-and-rotate-the-image": "Beskär och rotera bilden så att endast texten är synlig och den är åt rätt håll.",
|
"crop-and-rotate-the-image": "Beskär och rotera bilden så att endast texten är synlig och den är åt rätt håll.",
|
||||||
"create-from-images": "Skapa från bild",
|
"create-from-images": "Skapa från bild",
|
||||||
@@ -812,7 +812,7 @@
|
|||||||
"settings-updated": "Inställningar uppdaterade",
|
"settings-updated": "Inställningar uppdaterade",
|
||||||
"site-settings": "Systeminställningar",
|
"site-settings": "Systeminställningar",
|
||||||
"theme": {
|
"theme": {
|
||||||
"accent": "Accent",
|
"accent": "Accentfärg",
|
||||||
"dark": "Mörkt",
|
"dark": "Mörkt",
|
||||||
"default-to-system": "Standard",
|
"default-to-system": "Standard",
|
||||||
"error": "Fel",
|
"error": "Fel",
|
||||||
@@ -893,17 +893,17 @@
|
|||||||
"server-side-base-url-error-text": "`BASE_URL` är fortfarande standardvärdet på API-servern. Detta kommer att orsaka problem med meddelanden som genereras på servern för e-postmeddelanden, etc.",
|
"server-side-base-url-error-text": "`BASE_URL` är fortfarande standardvärdet på API-servern. Detta kommer att orsaka problem med meddelanden som genereras på servern för e-postmeddelanden, etc.",
|
||||||
"server-side-base-url-success-text": "Serversidans URL matchar inte standard",
|
"server-side-base-url-success-text": "Serversidans URL matchar inte standard",
|
||||||
"ldap-ready": "LDAP Redo",
|
"ldap-ready": "LDAP Redo",
|
||||||
"ldap-not-ready": "LDAP Not Ready",
|
"ldap-not-ready": "LDAP ej tillgängligt",
|
||||||
"ldap-ready-error-text": "Alla LDAP-värden är inte konfigurerade. Detta kan ignoreras om du inte använder LDAP-autentisering.",
|
"ldap-ready-error-text": "Alla LDAP-värden är inte konfigurerade. Detta kan ignoreras om du inte använder LDAP-autentisering.",
|
||||||
"ldap-ready-success-text": "Alla obligatoriska LDAP-variabler är satta.",
|
"ldap-ready-success-text": "Alla obligatoriska LDAP-variabler är satta.",
|
||||||
"build": "Bygge",
|
"build": "Bygge",
|
||||||
"recipe-scraper-version": "Version av Recept-scraper",
|
"recipe-scraper-version": "Version av Recept-scraper",
|
||||||
"oidc-ready": "OIDC Klar",
|
"oidc-ready": "OIDC Klar",
|
||||||
"oidc-not-ready": "OIDC Not Ready",
|
"oidc-not-ready": "OIDC ej tillgängligt",
|
||||||
"oidc-ready-error-text": "Alla OIDC-värden är inte konfigurerade. Detta kan ignoreras om du inte använder OIDC-autentisering.",
|
"oidc-ready-error-text": "Alla OIDC-värden är inte konfigurerade. Detta kan ignoreras om du inte använder OIDC-autentisering.",
|
||||||
"oidc-ready-success-text": "Alla obligatoriska OIDC-variabler är satta.",
|
"oidc-ready-success-text": "Alla obligatoriska OIDC-variabler är satta.",
|
||||||
"openai-ready": "OpenAI redo",
|
"openai-ready": "OpenAI redo",
|
||||||
"openai-not-ready": "OpenAI Not Ready",
|
"openai-not-ready": "OpenAI ej tillgängligt",
|
||||||
"openai-ready-error-text": "Alla OpenAI-värden är inte konfigurerade. Detta kan ignoreras om du inte använder OpenAI-funktioner.",
|
"openai-ready-error-text": "Alla OpenAI-värden är inte konfigurerade. Detta kan ignoreras om du inte använder OpenAI-funktioner.",
|
||||||
"openai-ready-success-text": "Alla obligatoriska OpenAI-variabler är satta."
|
"openai-ready-success-text": "Alla obligatoriska OpenAI-variabler är satta."
|
||||||
},
|
},
|
||||||
@@ -917,7 +917,7 @@
|
|||||||
"quantity": "Antal {0}",
|
"quantity": "Antal {0}",
|
||||||
"shopping-list": "Inköpslista",
|
"shopping-list": "Inköpslista",
|
||||||
"shopping-lists": "Inköpslistor",
|
"shopping-lists": "Inköpslistor",
|
||||||
"add-item": "Add item",
|
"add-item": "Lägg till vara",
|
||||||
"food": "Mat",
|
"food": "Mat",
|
||||||
"note": "Anteckning",
|
"note": "Anteckning",
|
||||||
"label": "Etikett",
|
"label": "Etikett",
|
||||||
@@ -943,7 +943,7 @@
|
|||||||
"are-you-sure-you-want-to-uncheck-all-items": "Är du säker på att du vill avmarkera alla objekt?",
|
"are-you-sure-you-want-to-uncheck-all-items": "Är du säker på att du vill avmarkera alla objekt?",
|
||||||
"are-you-sure-you-want-to-delete-checked-items": "Är du säker på att du vill ta bort alla markerade objekt?",
|
"are-you-sure-you-want-to-delete-checked-items": "Är du säker på att du vill ta bort alla markerade objekt?",
|
||||||
"no-shopping-lists-found": "Inga inköpslistor hittades",
|
"no-shopping-lists-found": "Inga inköpslistor hittades",
|
||||||
"item-checked-off": "Checked off {item}"
|
"item-checked-off": "Kryssat av {item}"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"all-recipes": "Recept",
|
"all-recipes": "Recept",
|
||||||
@@ -1478,10 +1478,10 @@
|
|||||||
"max-length": "Måste Vara Som Mest {max} Tecken|Måste Vara Som Mest {max} Tecken"
|
"max-length": "Måste Vara Som Mest {max} Tecken|Måste Vara Som Mest {max} Tecken"
|
||||||
},
|
},
|
||||||
"announcements": {
|
"announcements": {
|
||||||
"announcements": "Announcements",
|
"announcements": "Meddelanden",
|
||||||
"all-announcements": "All announcements",
|
"all-announcements": "Alla meddelanden",
|
||||||
"mark-all-as-read": "Mark All as Read",
|
"mark-all-as-read": "Markera alla som lästa",
|
||||||
"show-announcements-from-mealie": "Show announcements from Mealie",
|
"show-announcements-from-mealie": "Visa meddelanden från Mealie",
|
||||||
"show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings"
|
"show-announcements-setting-description": "Om du vill tillåta användare att se meddelanden från Mealie eller inte. När funktionen är aktiverad kan användarna fortfarande välja att inte se dem i sina användarinställningar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Sıfırdan yeni bir tarif oluşturun.",
|
"create-recipe-description": "Sıfırdan yeni bir tarif oluşturun.",
|
||||||
"create-recipes": "Tarif Oluştur",
|
"create-recipes": "Tarif Oluştur",
|
||||||
"import-with-zip": ".zip ile içe aktar",
|
"import-with-zip": ".zip ile içe aktar",
|
||||||
"create-recipe-from-an-image": "Görüntüden yemek tarifi oluştur",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Bir görsel yükleyerek yemek tarifi oluşturun. Mealie, yapay zekâ kullanarak görseldeki bilgileri çekip yemek tarifini oluşturmaya çalışacaktır.",
|
"create-recipe-from-an-image-description": "Bir görsel yükleyerek yemek tarifi oluşturun. Mealie, yapay zekâ kullanarak görseldeki bilgileri çekip yemek tarifini oluşturmaya çalışacaktır.",
|
||||||
"crop-and-rotate-the-image": "Resmi sadece yazılar gözükecek şekilde kesin ve döndürün, ayrıca doğru yönde durduğuna emin olun.",
|
"crop-and-rotate-the-image": "Resmi sadece yazılar gözükecek şekilde kesin ve döndürün, ayrıca doğru yönde durduğuna emin olun.",
|
||||||
"create-from-images": "Resimden Oluştur",
|
"create-from-images": "Resimden Oluştur",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Створити новий рецепт з нуля.",
|
"create-recipe-description": "Створити новий рецепт з нуля.",
|
||||||
"create-recipes": "Створити рецепти",
|
"create-recipes": "Створити рецепти",
|
||||||
"import-with-zip": "Імпорт з .zip",
|
"import-with-zip": "Імпорт з .zip",
|
||||||
"create-recipe-from-an-image": "Створити рецепт з зображення",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Створити рецепт, завантаживши його зображення. Mealie спробує витягти текст з зображення за допомогою ШІ та створити рецепт з нього.",
|
"create-recipe-from-an-image-description": "Створити рецепт, завантаживши його зображення. Mealie спробує витягти текст з зображення за допомогою ШІ та створити рецепт з нього.",
|
||||||
"crop-and-rotate-the-image": "Обріжте і поверніть зображення так, щоб було видно лише текст в правильній орієнтації.",
|
"crop-and-rotate-the-image": "Обріжте і поверніть зображення так, щоб було видно лише текст в правильній орієнтації.",
|
||||||
"create-from-images": "Створити з зображень",
|
"create-from-images": "Створити з зображень",
|
||||||
@@ -911,7 +911,7 @@
|
|||||||
"all-lists": "Всі списки",
|
"all-lists": "Всі списки",
|
||||||
"create-shopping-list": "Сторити список покупок",
|
"create-shopping-list": "Сторити список покупок",
|
||||||
"from-recipe": "З рецепту",
|
"from-recipe": "З рецепту",
|
||||||
"ingredient-of-recipe": "Ingredient of {recipe}",
|
"ingredient-of-recipe": "Інгредієнт з {recipe}",
|
||||||
"list-name": "Назва списку",
|
"list-name": "Назва списку",
|
||||||
"new-list": "Новий список",
|
"new-list": "Новий список",
|
||||||
"quantity": "Кількість: {0}",
|
"quantity": "Кількість: {0}",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "Create a new recipe from scratch.",
|
"create-recipe-description": "Create a new recipe from scratch.",
|
||||||
"create-recipes": "Create Recipes",
|
"create-recipes": "Create Recipes",
|
||||||
"import-with-zip": "Import with .zip",
|
"import-with-zip": "Import with .zip",
|
||||||
"create-recipe-from-an-image": "Create Recipe from an Image",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
||||||
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
|
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
|
||||||
"create-from-images": "Create from Images",
|
"create-from-images": "Create from Images",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "从头创建一个新食谱。",
|
"create-recipe-description": "从头创建一个新食谱。",
|
||||||
"create-recipes": "创建食谱",
|
"create-recipes": "创建食谱",
|
||||||
"import-with-zip": "使用 .zip 导入",
|
"import-with-zip": "使用 .zip 导入",
|
||||||
"create-recipe-from-an-image": "用图片创建食谱",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "通过上传食谱文本的图片来创建一个食谱。Mealie将尝试使用人工智能从图像中提取文本,并从中创建一个新的食谱。",
|
"create-recipe-from-an-image-description": "通过上传食谱文本的图片来创建一个食谱。Mealie将尝试使用人工智能从图像中提取文本,并从中创建一个新的食谱。",
|
||||||
"crop-and-rotate-the-image": "裁剪并旋转图片,使仅文字可见且方向正确。",
|
"crop-and-rotate-the-image": "裁剪并旋转图片,使仅文字可见且方向正确。",
|
||||||
"create-from-images": "从图片创建",
|
"create-from-images": "从图片创建",
|
||||||
|
|||||||
@@ -628,7 +628,7 @@
|
|||||||
"create-recipe-description": "從頭開始建立新食譜。",
|
"create-recipe-description": "從頭開始建立新食譜。",
|
||||||
"create-recipes": "建立食譜",
|
"create-recipes": "建立食譜",
|
||||||
"import-with-zip": "以 .zip 匯入",
|
"import-with-zip": "以 .zip 匯入",
|
||||||
"create-recipe-from-an-image": "從圖片建立食譜",
|
"create-recipe-from-images": "Create Recipe from Images",
|
||||||
"create-recipe-from-an-image-description": "上傳食譜圖片來建立食譜,Mealie 將嘗試使用 AI 從圖片中擷取文字並建立食譜。",
|
"create-recipe-from-an-image-description": "上傳食譜圖片來建立食譜,Mealie 將嘗試使用 AI 從圖片中擷取文字並建立食譜。",
|
||||||
"crop-and-rotate-the-image": "請裁切並旋轉圖片,使其僅顯示文字且方向正確。",
|
"crop-and-rotate-the-image": "請裁切並旋轉圖片,使其僅顯示文字且方向正確。",
|
||||||
"create-from-images": "從圖片建立",
|
"create-from-images": "從圖片建立",
|
||||||
|
|||||||
27
frontend/app/lib/api/admin/admin-ai-providers.ts
Normal file
27
frontend/app/lib/api/admin/admin-ai-providers.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { BaseAPI } from "../base/base-clients";
|
||||||
|
import type { AIProviderCreate, AIProviderOut, AIProviderUpdate } from "~/lib/api/types/group";
|
||||||
|
|
||||||
|
const prefix = "/api/admin";
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
providers: (groupId: string) => `${prefix}/groups/${groupId}/ai-providers/providers`,
|
||||||
|
providersId: (groupId: string, providerId: string) => `${prefix}/groups/${groupId}/ai-providers/providers/${providerId}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AdminAIProvidersApi extends BaseAPI {
|
||||||
|
async createProvider(groupId: string, payload: AIProviderCreate) {
|
||||||
|
return await this.requests.post<AIProviderOut>(routes.providers(groupId), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProvider(groupId: string, providerId: string) {
|
||||||
|
return await this.requests.get<AIProviderOut>(routes.providersId(groupId, providerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProvider(groupId: string, providerId: string, payload: AIProviderUpdate) {
|
||||||
|
return await this.requests.put<AIProviderOut>(routes.providersId(groupId, providerId), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProvider(groupId: string, providerId: string) {
|
||||||
|
return await this.requests.delete<AIProviderOut>(routes.providersId(groupId, providerId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,11 @@ import type { DebugResponse } from "~/lib/api/types/admin";
|
|||||||
const prefix = "/api";
|
const prefix = "/api";
|
||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
openai: `${prefix}/admin/debug/openai`,
|
openai: providerId => `${prefix}/admin/debug/openai/${providerId}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AdminDebugAPI extends BaseAPI {
|
export class AdminDebugAPI extends BaseAPI {
|
||||||
async debugOpenAI(fileObject: Blob | File | undefined = undefined, fileName = "") {
|
async debugOpenAI(providerId: string, fileObject: Blob | File | undefined = undefined, fileName = "") {
|
||||||
let formData: FormData | null = null;
|
let formData: FormData | null = null;
|
||||||
if (fileObject) {
|
if (fileObject) {
|
||||||
formData = new FormData();
|
formData = new FormData();
|
||||||
@@ -16,6 +16,6 @@ export class AdminDebugAPI extends BaseAPI {
|
|||||||
formData.append("extension", fileName.split(".").pop() ?? "");
|
formData.append("extension", fileName.split(".").pop() ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.requests.post<DebugResponse>(routes.openai, formData);
|
return await this.requests.post<DebugResponse>(routes.openai(providerId), formData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AdminBackupsApi } from "./admin/admin-backups";
|
|||||||
import { AdminMaintenanceApi } from "./admin/admin-maintenance";
|
import { AdminMaintenanceApi } from "./admin/admin-maintenance";
|
||||||
import { AdminAnalyticsApi } from "./admin/admin-analytics";
|
import { AdminAnalyticsApi } from "./admin/admin-analytics";
|
||||||
import { AdminDebugAPI } from "./admin/admin-debug";
|
import { AdminDebugAPI } from "./admin/admin-debug";
|
||||||
|
import { AdminAIProvidersApi } from "./admin/admin-ai-providers";
|
||||||
import type { ApiRequestInstance } from "~/lib/api/types/non-generated";
|
import type { ApiRequestInstance } from "~/lib/api/types/non-generated";
|
||||||
|
|
||||||
export class AdminAPI {
|
export class AdminAPI {
|
||||||
@@ -17,6 +18,7 @@ export class AdminAPI {
|
|||||||
public maintenance: AdminMaintenanceApi;
|
public maintenance: AdminMaintenanceApi;
|
||||||
public analytics: AdminAnalyticsApi;
|
public analytics: AdminAnalyticsApi;
|
||||||
public debug: AdminDebugAPI;
|
public debug: AdminDebugAPI;
|
||||||
|
public aiProviders: AdminAIProvidersApi;
|
||||||
|
|
||||||
constructor(requests: ApiRequestInstance) {
|
constructor(requests: ApiRequestInstance) {
|
||||||
this.about = new AdminAboutAPI(requests);
|
this.about = new AdminAboutAPI(requests);
|
||||||
@@ -27,6 +29,7 @@ export class AdminAPI {
|
|||||||
this.maintenance = new AdminMaintenanceApi(requests);
|
this.maintenance = new AdminMaintenanceApi(requests);
|
||||||
this.analytics = new AdminAnalyticsApi(requests);
|
this.analytics = new AdminAnalyticsApi(requests);
|
||||||
this.debug = new AdminDebugAPI(requests);
|
this.debug = new AdminDebugAPI(requests);
|
||||||
|
this.aiProviders = new AdminAIProvidersApi(requests);
|
||||||
|
|
||||||
Object.freeze(this);
|
Object.freeze(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { MultiPurposeLabelsApi } from "./user/group-multiple-purpose-labels";
|
|||||||
import { GroupEventNotifierApi } from "./user/group-event-notifier";
|
import { GroupEventNotifierApi } from "./user/group-event-notifier";
|
||||||
import { MealPlanRulesApi } from "./user/group-mealplan-rules";
|
import { MealPlanRulesApi } from "./user/group-mealplan-rules";
|
||||||
import { GroupDataSeederApi } from "./user/group-seeder";
|
import { GroupDataSeederApi } from "./user/group-seeder";
|
||||||
|
import { AIProvidersAPI } from "./user/group-ai-providers";
|
||||||
import type { ApiRequestInstance } from "~/lib/api/types/non-generated";
|
import type { ApiRequestInstance } from "~/lib/api/types/non-generated";
|
||||||
|
|
||||||
export class UserApiClient {
|
export class UserApiClient {
|
||||||
@@ -53,6 +54,7 @@ export class UserApiClient {
|
|||||||
public groupEventNotifier: GroupEventNotifierApi;
|
public groupEventNotifier: GroupEventNotifierApi;
|
||||||
public upload: UploadFile;
|
public upload: UploadFile;
|
||||||
public seeders: GroupDataSeederApi;
|
public seeders: GroupDataSeederApi;
|
||||||
|
public aiProviders: AIProvidersAPI;
|
||||||
|
|
||||||
constructor(requests: ApiRequestInstance) {
|
constructor(requests: ApiRequestInstance) {
|
||||||
// Recipes
|
// Recipes
|
||||||
@@ -80,6 +82,7 @@ export class UserApiClient {
|
|||||||
this.shopping = new ShoppingApi(requests);
|
this.shopping = new ShoppingApi(requests);
|
||||||
this.multiPurposeLabels = new MultiPurposeLabelsApi(requests);
|
this.multiPurposeLabels = new MultiPurposeLabelsApi(requests);
|
||||||
this.seeders = new GroupDataSeederApi(requests);
|
this.seeders = new GroupDataSeederApi(requests);
|
||||||
|
this.aiProviders = new AIProvidersAPI(requests);
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
this.backups = new BackupAPI(requests);
|
this.backups = new BackupAPI(requests);
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ export interface AdminAboutInfo {
|
|||||||
enableOidc: boolean;
|
enableOidc: boolean;
|
||||||
oidcRedirect: boolean;
|
oidcRedirect: boolean;
|
||||||
oidcProviderName: string;
|
oidcProviderName: string;
|
||||||
enableOpenai: boolean;
|
|
||||||
enableOpenaiImageServices: boolean;
|
|
||||||
enableOpenaiTranscriptionServices: boolean;
|
|
||||||
tokenTime: number;
|
tokenTime: number;
|
||||||
versionLatest: string;
|
versionLatest: string;
|
||||||
apiPort: number;
|
apiPort: number;
|
||||||
@@ -50,9 +47,6 @@ export interface AppInfo {
|
|||||||
enableOidc: boolean;
|
enableOidc: boolean;
|
||||||
oidcRedirect: boolean;
|
oidcRedirect: boolean;
|
||||||
oidcProviderName: string;
|
oidcProviderName: string;
|
||||||
enableOpenai: boolean;
|
|
||||||
enableOpenaiImageServices: boolean;
|
|
||||||
enableOpenaiTranscriptionServices: boolean;
|
|
||||||
tokenTime: number;
|
tokenTime: number;
|
||||||
}
|
}
|
||||||
export interface AppStartupInfo {
|
export interface AppStartupInfo {
|
||||||
@@ -95,7 +89,6 @@ export interface CheckAppConfig {
|
|||||||
emailReady: boolean;
|
emailReady: boolean;
|
||||||
ldapReady: boolean;
|
ldapReady: boolean;
|
||||||
oidcReady: boolean;
|
oidcReady: boolean;
|
||||||
enableOpenai: boolean;
|
|
||||||
baseUrlSet: boolean;
|
baseUrlSet: boolean;
|
||||||
isUpToDate: boolean;
|
isUpToDate: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,77 @@ export type SupportedMigrations =
|
|||||||
| "recipekeeper"
|
| "recipekeeper"
|
||||||
| "cookn";
|
| "cookn";
|
||||||
|
|
||||||
|
export interface AIProviderCreate {
|
||||||
|
name: string;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
model: string;
|
||||||
|
timeout?: number;
|
||||||
|
requestHeaders?: {
|
||||||
|
[k: string]: string;
|
||||||
|
};
|
||||||
|
requestParams?: {
|
||||||
|
[k: string]: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface AIProviderOut {
|
||||||
|
name: string;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
model: string;
|
||||||
|
timeout?: number;
|
||||||
|
requestHeaders?: {
|
||||||
|
[k: string]: string;
|
||||||
|
};
|
||||||
|
requestParams?: {
|
||||||
|
[k: string]: string;
|
||||||
|
};
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
export interface AIProviderSave {
|
||||||
|
name: string;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
model: string;
|
||||||
|
timeout?: number;
|
||||||
|
requestHeaders?: {
|
||||||
|
[k: string]: string;
|
||||||
|
};
|
||||||
|
requestParams?: {
|
||||||
|
[k: string]: string;
|
||||||
|
};
|
||||||
|
settingsId: string;
|
||||||
|
}
|
||||||
|
export interface AIProviderSettingsCreate {
|
||||||
|
groupId: string;
|
||||||
|
}
|
||||||
|
export interface AIProviderSettingsOut {
|
||||||
|
defaultProviderId: string | null;
|
||||||
|
audioProviderId: string | null;
|
||||||
|
imageProviderId: string | null;
|
||||||
|
providers: AIProviderSummary[];
|
||||||
|
aiEnabled: boolean;
|
||||||
|
audioProviderEnabled: boolean;
|
||||||
|
imageProviderEnabled: boolean;
|
||||||
|
}
|
||||||
|
export interface AIProviderSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
export interface AIProviderSettingsUpdate {
|
||||||
|
defaultProviderId: string | null;
|
||||||
|
audioProviderId: string | null;
|
||||||
|
imageProviderId: string | null;
|
||||||
|
}
|
||||||
|
export interface AIProviderUpdate {
|
||||||
|
name: string;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
model: string;
|
||||||
|
timeout?: number;
|
||||||
|
requestHeaders?: {
|
||||||
|
[k: string]: string;
|
||||||
|
};
|
||||||
|
requestParams?: {
|
||||||
|
[k: string]: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
export interface CreateGroupPreferences {
|
export interface CreateGroupPreferences {
|
||||||
privateGroup?: boolean;
|
privateGroup?: boolean;
|
||||||
showAnnouncements?: boolean;
|
showAnnouncements?: boolean;
|
||||||
@@ -29,6 +100,7 @@ export interface GroupAdminUpdate {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
preferences?: UpdateGroupPreferences | null;
|
preferences?: UpdateGroupPreferences | null;
|
||||||
|
aiProviderSettings?: AIProviderSettingsUpdate | null;
|
||||||
}
|
}
|
||||||
export interface UpdateGroupPreferences {
|
export interface UpdateGroupPreferences {
|
||||||
privateGroup?: boolean;
|
privateGroup?: boolean;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface GroupInDB {
|
|||||||
households?: GroupHouseholdSummary[] | null;
|
households?: GroupHouseholdSummary[] | null;
|
||||||
users?: UserSummary[] | null;
|
users?: UserSummary[] | null;
|
||||||
preferences?: ReadGroupPreferences | null;
|
preferences?: ReadGroupPreferences | null;
|
||||||
|
aiProviderSettings?: AIProviderSettingsOut | null;
|
||||||
}
|
}
|
||||||
export interface CategoryBase {
|
export interface CategoryBase {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -89,11 +90,25 @@ export interface ReadGroupPreferences {
|
|||||||
groupId: string;
|
groupId: string;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
export interface AIProviderSettingsOut {
|
||||||
|
defaultProviderId: string | null;
|
||||||
|
audioProviderId: string | null;
|
||||||
|
imageProviderId: string | null;
|
||||||
|
providers: AIProviderSummary[];
|
||||||
|
aiEnabled: boolean;
|
||||||
|
audioProviderEnabled: boolean;
|
||||||
|
imageProviderEnabled: boolean;
|
||||||
|
}
|
||||||
|
export interface AIProviderSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
export interface GroupSummary {
|
export interface GroupSummary {
|
||||||
name: string;
|
name: string;
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
preferences?: ReadGroupPreferences | null;
|
preferences?: ReadGroupPreferences | null;
|
||||||
|
aiProviderSettings?: AIProviderSettingsOut | null;
|
||||||
}
|
}
|
||||||
export interface LongLiveTokenCreateResponse {
|
export interface LongLiveTokenCreateResponse {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
27
frontend/app/lib/api/user/group-ai-providers.ts
Normal file
27
frontend/app/lib/api/user/group-ai-providers.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { BaseAPI } from "../base/base-clients";
|
||||||
|
import type { AIProviderCreate, AIProviderOut, AIProviderUpdate } from "~/lib/api/types/group";
|
||||||
|
|
||||||
|
const prefix = "/api/groups/ai-providers";
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
providers: `${prefix}/providers`,
|
||||||
|
providersId: (id: string) => `${prefix}/providers/${id}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AIProvidersAPI extends BaseAPI {
|
||||||
|
async getOne(id: string) {
|
||||||
|
return await this.requests.get<AIProviderOut>(routes.providersId(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOne(payload: AIProviderCreate) {
|
||||||
|
return await this.requests.post<AIProviderOut>(routes.providers, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOne(id: string, payload: AIProviderUpdate) {
|
||||||
|
return await this.requests.put<AIProviderOut, AIProviderUpdate>(routes.providersId(id), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOne(id: string) {
|
||||||
|
return await this.requests.delete<AIProviderOut>(routes.providersId(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import type { PaginationData } from "../types/non-generated";
|
|||||||
import type { QueryValue } from "../base/route";
|
import type { QueryValue } from "../base/route";
|
||||||
import type { GroupBase, GroupInDB, GroupSummary, UserSummary } from "~/lib/api/types/user";
|
import type { GroupBase, GroupInDB, GroupSummary, UserSummary } from "~/lib/api/types/user";
|
||||||
import type {
|
import type {
|
||||||
|
AIProviderSettingsUpdate,
|
||||||
|
AIProviderSettingsOut,
|
||||||
GroupAdminUpdate,
|
GroupAdminUpdate,
|
||||||
GroupStorage,
|
GroupStorage,
|
||||||
ReadGroupPreferences,
|
ReadGroupPreferences,
|
||||||
@@ -15,6 +17,7 @@ const routes = {
|
|||||||
groups: `${prefix}/admin/groups`,
|
groups: `${prefix}/admin/groups`,
|
||||||
groupsSelf: `${prefix}/groups/self`,
|
groupsSelf: `${prefix}/groups/self`,
|
||||||
preferences: `${prefix}/groups/preferences`,
|
preferences: `${prefix}/groups/preferences`,
|
||||||
|
aiProviderSettings: `${prefix}/groups/ai-providers/settings`,
|
||||||
storage: `${prefix}/groups/storage`,
|
storage: `${prefix}/groups/storage`,
|
||||||
members: `${prefix}/groups/members`,
|
members: `${prefix}/groups/members`,
|
||||||
groupsId: (id: string | number) => `${prefix}/admin/groups/${id}`,
|
groupsId: (id: string | number) => `${prefix}/admin/groups/${id}`,
|
||||||
@@ -29,15 +32,15 @@ export class GroupAPI extends BaseCRUDAPI<GroupBase, GroupInDB, GroupAdminUpdate
|
|||||||
return await this.requests.get<GroupSummary>(routes.groupsSelf);
|
return await this.requests.get<GroupSummary>(routes.groupsSelf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPreferences() {
|
|
||||||
return await this.requests.get<ReadGroupPreferences>(routes.preferences);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setPreferences(payload: UpdateGroupPreferences) {
|
async setPreferences(payload: UpdateGroupPreferences) {
|
||||||
// TODO: This should probably be a patch request, which isn't offered by the API currently
|
// TODO: This should probably be a patch request, which isn't offered by the API currently
|
||||||
return await this.requests.put<ReadGroupPreferences, UpdateGroupPreferences>(routes.preferences, payload);
|
return await this.requests.put<ReadGroupPreferences, UpdateGroupPreferences>(routes.preferences, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setAIProviderSettings(payload: AIProviderSettingsUpdate) {
|
||||||
|
return await this.requests.put<AIProviderSettingsOut, AIProviderSettingsUpdate>(routes.aiProviderSettings, payload);
|
||||||
|
}
|
||||||
|
|
||||||
async fetchMembers(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
|
async fetchMembers(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
|
||||||
return await this.requests.get<PaginationData<UserSummary>>(routes.members, { page, perPage, ...params });
|
return await this.requests.get<PaginationData<UserSummary>>(routes.members, { page, perPage, ...params });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,6 @@ export class HouseholdAPI extends BaseCRUDAPIReadOnly<HouseholdSummary> {
|
|||||||
return await this.requests.get<HouseholdRecipeSummary>(routes.householdsSelfRecipesSlug(recipeSlug));
|
return await this.requests.get<HouseholdRecipeSummary>(routes.householdsSelfRecipesSlug(recipeSlug));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPreferences() {
|
|
||||||
return await this.requests.get<ReadHouseholdPreferences>(routes.preferences);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setPreferences(payload: UpdateHouseholdPreferences) {
|
async setPreferences(payload: UpdateHouseholdPreferences) {
|
||||||
// TODO: This should probably be a patch request, which isn't offered by the API currently
|
// TODO: This should probably be a patch request, which isn't offered by the API currently
|
||||||
return await this.requests.put<ReadHouseholdPreferences, UpdateHouseholdPreferences>(routes.preferences, payload);
|
return await this.requests.put<ReadHouseholdPreferences, UpdateHouseholdPreferences>(routes.preferences, payload);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<br>
|
<br>
|
||||||
<DocLink
|
<DocLink
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
link="/documentation/getting-started/installation/open-ai"
|
link="/documentation/getting-started/installation/ai-providers"
|
||||||
/>
|
/>
|
||||||
</BaseCardSectionTitle>
|
</BaseCardSectionTitle>
|
||||||
</v-container>
|
</v-container>
|
||||||
@@ -17,6 +17,36 @@
|
|||||||
<div>
|
<div>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-container class="pa-0">
|
<v-container class="pa-0">
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-select
|
||||||
|
v-if="groups"
|
||||||
|
v-model="selectedGroupId"
|
||||||
|
:items="groups"
|
||||||
|
item-title="name"
|
||||||
|
item-value="id"
|
||||||
|
:label="$t('group.group')"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-select
|
||||||
|
v-model="selectedProviderId"
|
||||||
|
:items="groupProviders"
|
||||||
|
item-title="name"
|
||||||
|
item-value="id"
|
||||||
|
:label="$t('group.ai-provider-settings.ai-provider')"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
:disabled="!selectedGroupId"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col
|
<v-col
|
||||||
cols="auto"
|
cols="auto"
|
||||||
@@ -61,6 +91,7 @@
|
|||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
type="submit"
|
type="submit"
|
||||||
|
:disabled="!selectedProviderId"
|
||||||
:text="$t('admin.run-test')"
|
:text="$t('admin.run-test')"
|
||||||
:icon="$globals.icons.check"
|
:icon="$globals.icons.check"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@@ -85,7 +116,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAdminApi } from "~/composables/api";
|
import { useAdminApi } from "~/composables/api";
|
||||||
|
import { useGroups } from "~/composables/use-groups";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
|
import type { AIProviderSummary } from "~/lib/api/types/group";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "admin",
|
layout: "admin",
|
||||||
@@ -106,10 +139,24 @@ const uploadedImage = ref<Blob | File>();
|
|||||||
const uploadedImageName = ref<string>("");
|
const uploadedImageName = ref<string>("");
|
||||||
const uploadedImagePreviewUrl = ref<string>();
|
const uploadedImagePreviewUrl = ref<string>();
|
||||||
|
|
||||||
function uploadImage(fileObject: File) {
|
// Group + provider selection
|
||||||
uploadedImage.value = fileObject;
|
const { groups } = useGroups();
|
||||||
uploadedImageName.value = fileObject.name;
|
const selectedGroupId = ref<string | null>(null);
|
||||||
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
const groupProviders = ref<AIProviderSummary[]>([]);
|
||||||
|
const selectedProviderId = ref<string | null>(null);
|
||||||
|
|
||||||
|
watch(selectedGroupId, (id) => {
|
||||||
|
groupProviders.value = [];
|
||||||
|
selectedProviderId.value = null;
|
||||||
|
if (!id) return;
|
||||||
|
const group = groups.value?.find(g => g.id === id);
|
||||||
|
groupProviders.value = group?.aiProviderSettings?.providers ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function uploadImage(fileObject: unknown) {
|
||||||
|
uploadedImage.value = fileObject as File;
|
||||||
|
uploadedImageName.value = (fileObject as File).name;
|
||||||
|
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject as File);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearImage() {
|
function clearImage() {
|
||||||
@@ -119,10 +166,15 @@ function clearImage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function testOpenAI() {
|
async function testOpenAI() {
|
||||||
|
if (!selectedProviderId.value) {
|
||||||
|
alert.error("Please select a provider");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
response.value = "";
|
response.value = "";
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const { data } = await api.debug.debugOpenAI(uploadedImage.value);
|
const { data } = await api.debug.debugOpenAI(selectedProviderId.value, uploadedImage.value);
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
|
|||||||
@@ -33,6 +33,13 @@
|
|||||||
v-if="group.preferences"
|
v-if="group.preferences"
|
||||||
v-model="group.preferences"
|
v-model="group.preferences"
|
||||||
/>
|
/>
|
||||||
|
<GroupAIProviderSettingsEditor
|
||||||
|
v-if="group.aiProviderSettings"
|
||||||
|
v-model="group.aiProviderSettings"
|
||||||
|
@create="handleCreateProvider"
|
||||||
|
@update="handleUpdateProvider"
|
||||||
|
@delete="handleDeleteProvider"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
<div class="d-flex pa-2">
|
<div class="d-flex pa-2">
|
||||||
@@ -50,8 +57,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
|
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
|
||||||
|
import GroupAIProviderSettingsEditor from "~/components/Domain/Group/GroupAIProviderSettingsEditor.vue";
|
||||||
import { useAdminApi } from "~/composables/api";
|
import { useAdminApi } from "~/composables/api";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
|
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
|
||||||
import type { VForm } from "vuetify/components";
|
import type { VForm } from "vuetify/components";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
@@ -72,7 +81,7 @@ const adminApi = useAdminApi();
|
|||||||
|
|
||||||
const userError = ref(false);
|
const userError = ref(false);
|
||||||
|
|
||||||
const { data: group } = useLazyAsyncData(`get-household-${groupId.value}`, async () => {
|
const { data: group, refresh } = useLazyAsyncData(`get-household-${groupId.value}`, async () => {
|
||||||
if (!groupId.value) {
|
if (!groupId.value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -86,7 +95,7 @@ const { data: group } = useLazyAsyncData(`get-household-${groupId.value}`, async
|
|||||||
}, { watch: [groupId] });
|
}, { watch: [groupId] });
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!refGroupEditForm.value?.validate() || group.value === null) {
|
if (!refGroupEditForm.value?.validate() || !group.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,4 +112,40 @@ async function handleSubmit() {
|
|||||||
alert.error(i18n.t("settings.settings-update-failed"));
|
alert.error(i18n.t("settings.settings-update-failed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreateProvider(data: AIProviderCreate) {
|
||||||
|
if (!group.value) return;
|
||||||
|
const result = await adminApi.aiProviders.createProvider(group.value.id, data);
|
||||||
|
if (result.data) {
|
||||||
|
await refresh();
|
||||||
|
alert.success(i18n.t("group.ai-provider-settings.provider-created"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("group.ai-provider-settings.provider-create-failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateProvider(id: string, data: AIProviderUpdate) {
|
||||||
|
if (!group.value) return;
|
||||||
|
const result = await adminApi.aiProviders.updateProvider(group.value.id, id, data);
|
||||||
|
if (result.data) {
|
||||||
|
await refresh();
|
||||||
|
alert.success(i18n.t("group.ai-provider-settings.provider-updated"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("group.ai-provider-settings.provider-update-failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteProvider(id: string) {
|
||||||
|
if (!group.value) return;
|
||||||
|
const result = await adminApi.aiProviders.deleteProvider(group.value.id, id);
|
||||||
|
if (result.data) {
|
||||||
|
await refresh();
|
||||||
|
alert.success(i18n.t("group.ai-provider-settings.provider-deleted"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("group.ai-provider-settings.provider-delete-failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -45,6 +45,14 @@
|
|||||||
:title="$t('settings.site-settings')"
|
:title="$t('settings.site-settings')"
|
||||||
/>
|
/>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
|
<v-stepper-item
|
||||||
|
:value="Pages.AI_PROVIDERS"
|
||||||
|
:icon="$globals.icons.robot"
|
||||||
|
:complete="currentPage > Pages.AI_PROVIDERS"
|
||||||
|
:color="getStepperColor(currentPage, Pages.AI_PROVIDERS)"
|
||||||
|
:title="$t('group.ai-provider-settings.ai-providers')"
|
||||||
|
/>
|
||||||
|
<v-divider />
|
||||||
<v-stepper-item
|
<v-stepper-item
|
||||||
:value="Pages.CONFIRM"
|
:value="Pages.CONFIRM"
|
||||||
:icon="$globals.icons.chefHat"
|
:icon="$globals.icons.chefHat"
|
||||||
@@ -173,6 +181,43 @@
|
|||||||
</v-stepper-actions>
|
</v-stepper-actions>
|
||||||
</v-stepper-window-item>
|
</v-stepper-window-item>
|
||||||
|
|
||||||
|
<!-- AI PROVIDERS -->
|
||||||
|
<v-stepper-window-item :value="Pages.AI_PROVIDERS">
|
||||||
|
<v-container max-width="880">
|
||||||
|
<v-card-title class="headline pa-0">
|
||||||
|
{{ $t('group.ai-provider-settings.ai-providers') }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-subtitle class="px-0 py-2 text-wrap">
|
||||||
|
{{ $t('group.ai-provider-settings.ai-providers-description') }}
|
||||||
|
</v-card-subtitle>
|
||||||
|
<GroupAIProviderSettingsEditor
|
||||||
|
v-if="group?.aiProviderSettings"
|
||||||
|
v-model="group.aiProviderSettings"
|
||||||
|
hide-header
|
||||||
|
class="mt-4"
|
||||||
|
@create="handleCreateProvider"
|
||||||
|
@update="handleUpdateProvider"
|
||||||
|
@delete="handleDeleteProvider"
|
||||||
|
/>
|
||||||
|
</v-container>
|
||||||
|
<v-stepper-actions
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
prev-text="general.back"
|
||||||
|
@click:prev="onPrev"
|
||||||
|
>
|
||||||
|
<template #next>
|
||||||
|
<v-btn
|
||||||
|
variant="flat"
|
||||||
|
color="success"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
:text="$t('general.next')"
|
||||||
|
@click="onNext"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-stepper-actions>
|
||||||
|
</v-stepper-window-item>
|
||||||
|
|
||||||
<!-- CONFIRMATION -->
|
<!-- CONFIRMATION -->
|
||||||
<v-stepper-window-item :value="Pages.CONFIRM">
|
<v-stepper-window-item :value="Pages.CONFIRM">
|
||||||
<v-container max-width="880">
|
<v-container max-width="880">
|
||||||
@@ -252,7 +297,11 @@ import { useLocales } from "~/composables/use-locales";
|
|||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
|
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
|
||||||
import { useCommonSettingsForm } from "~/composables/use-setup/common-settings-form";
|
import { useCommonSettingsForm } from "~/composables/use-setup/common-settings-form";
|
||||||
|
import { useGroupSelf } from "~/composables/use-groups";
|
||||||
|
import { useAIProviders } from "~/composables/use-ai-providers";
|
||||||
import UserRegistrationForm from "~/components/Domain/User/UserRegistrationForm.vue";
|
import UserRegistrationForm from "~/components/Domain/User/UserRegistrationForm.vue";
|
||||||
|
import GroupAIProviderSettingsEditor from "~/components/Domain/Group/GroupAIProviderSettingsEditor.vue";
|
||||||
|
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "blank",
|
layout: "blank",
|
||||||
@@ -267,6 +316,42 @@ const userApi = useUserApi();
|
|||||||
const adminApi = useAdminApi();
|
const adminApi = useAdminApi();
|
||||||
|
|
||||||
const groupSlug = computed(() => auth.user.value?.groupSlug);
|
const groupSlug = computed(() => auth.user.value?.groupSlug);
|
||||||
|
|
||||||
|
const { group, actions: groupActions } = useGroupSelf();
|
||||||
|
const { createOne, updateOne, deleteOne } = useAIProviders();
|
||||||
|
|
||||||
|
async function handleCreateProvider(data: AIProviderCreate) {
|
||||||
|
const result = await createOne(data);
|
||||||
|
if (result.data) {
|
||||||
|
await groupActions.refresh();
|
||||||
|
alert.success(i18n.t("group.ai-provider-settings.provider-created"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("group.ai-provider-settings.provider-create-failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateProvider(id: string, data: AIProviderUpdate) {
|
||||||
|
const result = await updateOne(id, data);
|
||||||
|
if (result.data) {
|
||||||
|
await groupActions.refresh();
|
||||||
|
alert.success(i18n.t("group.ai-provider-settings.provider-updated"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("group.ai-provider-settings.provider-update-failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteProvider(id: string) {
|
||||||
|
const result = await deleteOne(id);
|
||||||
|
if (result.data) {
|
||||||
|
await groupActions.refresh();
|
||||||
|
alert.success(i18n.t("group.ai-provider-settings.provider-deleted"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("group.ai-provider-settings.provider-delete-failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
const { locale } = useLocales();
|
const { locale } = useLocales();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
@@ -281,8 +366,9 @@ enum Pages {
|
|||||||
LANDING = 1,
|
LANDING = 1,
|
||||||
USER_INFO = 2,
|
USER_INFO = 2,
|
||||||
PAGE_2 = 3,
|
PAGE_2 = 3,
|
||||||
CONFIRM = 4,
|
AI_PROVIDERS = 4,
|
||||||
END = 5,
|
CONFIRM = 5,
|
||||||
|
END = 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStepperColor(currentPage: Pages, page: Pages) {
|
function getStepperColor(currentPage: Pages, page: Pages) {
|
||||||
@@ -475,6 +561,7 @@ async function submitAll() {
|
|||||||
const tasks = [
|
const tasks = [
|
||||||
submitRegistration(),
|
submitRegistration(),
|
||||||
submitCommonSettings(),
|
submitCommonSettings(),
|
||||||
|
groupActions.updateAIProviderSettings(),
|
||||||
];
|
];
|
||||||
|
|
||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
|
|||||||
@@ -284,7 +284,6 @@ const appConfig = ref<CheckApp>({
|
|||||||
isUpToDate: false,
|
isUpToDate: false,
|
||||||
ldapReady: false,
|
ldapReady: false,
|
||||||
oidcReady: false,
|
oidcReady: false,
|
||||||
enableOpenai: false,
|
|
||||||
});
|
});
|
||||||
function isLocalHostOrHttps() {
|
function isLocalHostOrHttps() {
|
||||||
return window.location.hostname === "localhost" || window.location.protocol === "https:";
|
return window.location.hostname === "localhost" || window.location.protocol === "https:";
|
||||||
@@ -351,15 +350,6 @@ const simpleChecks = computed<SimpleCheck[]>(() => {
|
|||||||
color: appConfig.value.oidcReady ? goodColor : warningColor,
|
color: appConfig.value.oidcReady ? goodColor : warningColor,
|
||||||
icon: appConfig.value.oidcReady ? goodIcon : warningIcon,
|
icon: appConfig.value.oidcReady ? goodIcon : warningIcon,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "openai-ready",
|
|
||||||
text: appConfig.value.enableOpenai ? i18n.t("settings.openai-ready") : i18n.t("settings.openai-not-ready"),
|
|
||||||
status: appConfig.value.enableOpenai,
|
|
||||||
errorText: i18n.t("settings.openai-ready-error-text"),
|
|
||||||
successText: i18n.t("settings.openai-ready-success-text"),
|
|
||||||
color: appConfig.value.enableOpenai ? goodColor : warningColor,
|
|
||||||
icon: appConfig.value.enableOpenai ? goodIcon : warningIcon,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MenuItem } from "~/components/global/BaseOverflowButton.vue";
|
import type { MenuItem } from "~/components/global/BaseOverflowButton.vue";
|
||||||
import AdvancedOnly from "~/components/global/AdvancedOnly.vue";
|
import AdvancedOnly from "~/components/global/AdvancedOnly.vue";
|
||||||
|
import { useGroupSelf } from "~/composables/use-groups";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ["group-only"],
|
middleware: ["group-only"],
|
||||||
@@ -52,7 +53,8 @@ definePageMeta({
|
|||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const { $appInfo, $globals } = useNuxtApp();
|
const { $globals } = useNuxtApp();
|
||||||
|
const { group } = useGroupSelf();
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: i18n.t("general.create"),
|
title: i18n.t("general.create"),
|
||||||
@@ -78,7 +80,7 @@ const subpages = computed<MenuItem[]>(() => [
|
|||||||
icon: $globals.icons.fileImage,
|
icon: $globals.icons.fileImage,
|
||||||
text: i18n.t("recipe.create-from-images"),
|
text: i18n.t("recipe.create-from-images"),
|
||||||
value: "image",
|
value: "image",
|
||||||
hide: !$appInfo.enableOpenaiImageServices,
|
hide: !group.value?.aiProviderSettings?.imageProviderEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.edit,
|
icon: $globals.icons.edit,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-text v-if="$appInfo.enableOpenai">
|
<v-card-text v-if="group?.aiProviderSettings?.aiEnabled">
|
||||||
{{ $t('recipe.recipe-debugger-use-openai-description') }}
|
{{ $t('recipe.recipe-debugger-use-openai-description') }}
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-model="state.useOpenAI"
|
v-model="state.useOpenAI"
|
||||||
@@ -69,6 +69,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { useGroupSelf } from "~/composables/use-groups";
|
||||||
import { validators } from "~/composables/use-validators";
|
import { validators } from "~/composables/use-validators";
|
||||||
import type { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ const state = reactive({
|
|||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { group } = useGroupSelf();
|
||||||
|
|
||||||
const recipeUrl = computed({
|
const recipeUrl = computed({
|
||||||
set(recipe_import_url: string | null) {
|
set(recipe_import_url: string | null) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<v-form ref="domUrlForm" @submit.prevent="createRecipe">
|
<v-form ref="domUrlForm" @submit.prevent="createRecipe">
|
||||||
<div>
|
<div>
|
||||||
<v-card-title class="headline">
|
<v-card-title class="headline">
|
||||||
{{ $t("recipe.create-recipe-from-an-image") }}
|
{{ $t("recipe.create-recipe-from-images") }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<p>{{ $t("recipe.create-recipe-from-an-image-description") }}</p>
|
<p>{{ $t("recipe.create-recipe-from-an-image-description") }}</p>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-card-text class="pa-0">
|
<v-card-text class="pa-0">
|
||||||
<p>{{ $t('recipe.scrape-recipe-description') }}</p>
|
<p>{{ $t('recipe.scrape-recipe-description') }}</p>
|
||||||
<p v-if="$appInfo.enableOpenaiTranscriptionServices">
|
<p v-if="group?.aiProviderSettings?.audioProviderEnabled">
|
||||||
{{ $t('recipe.scrape-recipe-description-transcription') }}
|
{{ $t('recipe.scrape-recipe-description-transcription') }}
|
||||||
</p>
|
</p>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
@@ -145,6 +145,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { useGroupSelf } from "~/composables/use-groups";
|
||||||
import { useTagStore } from "~/composables/store/use-tag-store";
|
import { useTagStore } from "~/composables/store/use-tag-store";
|
||||||
import { useNewRecipeOptions } from "~/composables/use-new-recipe-options";
|
import { useNewRecipeOptions } from "~/composables/use-new-recipe-options";
|
||||||
import { validators } from "~/composables/use-validators";
|
import { validators } from "~/composables/use-validators";
|
||||||
@@ -162,6 +163,7 @@ const auth = useMealieAuth();
|
|||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||||
|
const { group } = useGroupSelf();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const tags = useTagStore();
|
const tags = useTagStore();
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
</template>
|
</template>
|
||||||
{{ $t("profile.group-description") }}
|
{{ $t("profile.group-description") }}
|
||||||
</BasePageTitle>
|
</BasePageTitle>
|
||||||
<v-form ref="refGroupEditForm" @submit.prevent="handleSubmit">
|
|
||||||
|
<div class="mb-10">
|
||||||
|
<v-form ref="refGroupPrefsEditForm" @submit.prevent="handlePrefsSubmit">
|
||||||
<v-card variant="outlined" style="border-color: lightgray;">
|
<v-card variant="outlined" style="border-color: lightgray;">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<GroupPreferencesEditor v-if="group.preferences" v-model="group.preferences" />
|
<GroupPreferencesEditor v-if="group.preferences" v-model="group.preferences" />
|
||||||
@@ -29,13 +31,38 @@
|
|||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</v-form>
|
</v-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<v-form ref="refGroupAISettingsForm" @submit.prevent="handleAISettingsSubmit">
|
||||||
|
<v-card variant="outlined" style="border-color: lightgray;">
|
||||||
|
<v-card-text>
|
||||||
|
<GroupAIProviderSettingsEditor
|
||||||
|
v-if="group.aiProviderSettings"
|
||||||
|
v-model="group.aiProviderSettings"
|
||||||
|
@create="handleCreateProvider"
|
||||||
|
@update="handleUpdateProvider"
|
||||||
|
@delete="handleDeleteProvider"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
<div class="d-flex pa-2">
|
||||||
|
<BaseButton type="submit" edit class="ml-auto">
|
||||||
|
{{ $t("general.update") }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</v-form>
|
||||||
|
</div>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
|
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
|
||||||
|
import GroupAIProviderSettingsEditor from "~/components/Domain/Group/GroupAIProviderSettingsEditor.vue";
|
||||||
import { useGroupSelf } from "~/composables/use-groups";
|
import { useGroupSelf } from "~/composables/use-groups";
|
||||||
|
import { useAIProviders } from "~/composables/use-ai-providers";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
|
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
|
||||||
import type { VForm } from "~/types/auto-forms";
|
import type { VForm } from "~/types/auto-forms";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
@@ -49,10 +76,11 @@ useSeoMeta({
|
|||||||
title: i18n.t("group.group"),
|
title: i18n.t("group.group"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const refGroupEditForm = ref<VForm | null>(null);
|
const refGroupPrefsEditForm = ref<VForm | null>(null);
|
||||||
|
const refGroupAISettingsForm = ref<VForm | null>(null);
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handlePrefsSubmit() {
|
||||||
if (!refGroupEditForm.value?.validate() || !group.value?.preferences) {
|
if (!refGroupPrefsEditForm.value?.validate() || !group.value?.preferences) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +92,55 @@ async function handleSubmit() {
|
|||||||
alert.error(i18n.t("settings.settings-update-failed"));
|
alert.error(i18n.t("settings.settings-update-failed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAISettingsSubmit() {
|
||||||
|
if (!refGroupAISettingsForm.value?.validate() || !group.value?.aiProviderSettings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await groupActions.updateAIProviderSettings();
|
||||||
|
if (data) {
|
||||||
|
alert.success(i18n.t("settings.settings-updated"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("settings.settings-update-failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createOne, updateOne, deleteOne } = useAIProviders();
|
||||||
|
|
||||||
|
async function handleCreateProvider(data: AIProviderCreate) {
|
||||||
|
const result = await createOne(data);
|
||||||
|
if (result.data) {
|
||||||
|
await groupActions.refresh();
|
||||||
|
alert.success(i18n.t("group.ai-provider-settings.provider-created"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("group.ai-provider-settings.provider-create-failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateProvider(id: string, data: AIProviderUpdate) {
|
||||||
|
const result = await updateOne(id, data);
|
||||||
|
if (result.data) {
|
||||||
|
await groupActions.refresh();
|
||||||
|
alert.success(i18n.t("group.ai-provider-settings.provider-updated"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("group.ai-provider-settings.provider-update-failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteProvider(id: string) {
|
||||||
|
const result = await deleteOne(id);
|
||||||
|
if (result.data) {
|
||||||
|
await groupActions.refresh();
|
||||||
|
alert.success(i18n.t("group.ai-provider-settings.provider-deleted"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("group.ai-provider-settings.provider-delete-failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="css">
|
<style lang="css">
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
sm="12"
|
sm="12"
|
||||||
md="12"
|
md="12"
|
||||||
>
|
>
|
||||||
<v-card variant="outlined" style="border-color: lightgray;" class="mt-4">
|
<v-card variant="outlined" style="border-color: lightgray;" class="mt-4 pa-2">
|
||||||
<v-card-title class="text-h6 pb-0">
|
<v-card-title class="text-h6 pb-0">
|
||||||
{{ $t('profile.household-statistics') }}
|
{{ $t('profile.household-statistics') }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|||||||
65
frontend/app/plugins/no-autofill.client.ts
Normal file
65
frontend/app/plugins/no-autofill.client.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* v-no-autofill directive
|
||||||
|
*
|
||||||
|
* Vuetify 3 places data-* attributes on its wrapper div, not the underlying
|
||||||
|
* <input> element, so password managers still offer to autofill. This directive
|
||||||
|
* uses a MutationObserver to find and patch every <input> inside the host
|
||||||
|
* element, even ones rendered asynchronously (dialogs, conditional blocks).
|
||||||
|
*
|
||||||
|
* From: https://github.com/vuetifyjs/vuetify/issues/18202
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <v-text-field v-no-autofill ... />
|
||||||
|
* <v-form v-no-autofill>...</v-form>
|
||||||
|
* <div v-no-autofill>...</div>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Directive, DirectiveBinding } from "vue";
|
||||||
|
|
||||||
|
interface ObservedElement extends HTMLElement {
|
||||||
|
_noAutofillObserver?: MutationObserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchInput(input: HTMLInputElement) {
|
||||||
|
input.setAttribute("autocomplete", "off");
|
||||||
|
input.setAttribute("data-1p-ignore", "true");
|
||||||
|
input.setAttribute("data-lpignore", "true");
|
||||||
|
input.setAttribute("data-protonpass-ignore", "true");
|
||||||
|
input.setAttribute("data-bwignore", "true");
|
||||||
|
input.setAttribute("data-form-type", "other");
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchAll(el: HTMLElement) {
|
||||||
|
if (el.tagName === "INPUT") {
|
||||||
|
patchInput(el as HTMLInputElement);
|
||||||
|
}
|
||||||
|
el.querySelectorAll<HTMLInputElement>("input").forEach(patchInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noAutofill: Directive<ObservedElement> = {
|
||||||
|
mounted(el: ObservedElement, _binding: DirectiveBinding) {
|
||||||
|
patchAll(el);
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const node of mutation.addedNodes) {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
patchAll(node as HTMLElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(el, { childList: true, subtree: true });
|
||||||
|
el._noAutofillObserver = observer;
|
||||||
|
},
|
||||||
|
|
||||||
|
unmounted(el: ObservedElement) {
|
||||||
|
el._noAutofillObserver?.disconnect();
|
||||||
|
delete el._noAutofillObserver;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
nuxtApp.vueApp.directive("no-autofill", noAutofill);
|
||||||
|
});
|
||||||
2
frontend/app/types/components.d.ts
vendored
2
frontend/app/types/components.d.ts
vendored
@@ -14,6 +14,7 @@ import type BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.
|
|||||||
import type BaseDialog from "@/components/global/BaseDialog.vue";
|
import type BaseDialog from "@/components/global/BaseDialog.vue";
|
||||||
import type BaseDivider from "@/components/global/BaseDivider.vue";
|
import type BaseDivider from "@/components/global/BaseDivider.vue";
|
||||||
import type BaseExpansionPanels from "@/components/global/BaseExpansionPanels.vue";
|
import type BaseExpansionPanels from "@/components/global/BaseExpansionPanels.vue";
|
||||||
|
import type BaseKeyValueEditor from "@/components/global/BaseKeyValueEditor.vue";
|
||||||
import type BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
|
import type BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
|
||||||
import type BasePageTitle from "@/components/global/BasePageTitle.vue";
|
import type BasePageTitle from "@/components/global/BasePageTitle.vue";
|
||||||
import type ButtonLink from "@/components/global/ButtonLink.vue";
|
import type ButtonLink from "@/components/global/ButtonLink.vue";
|
||||||
@@ -54,6 +55,7 @@ declare module "vue" {
|
|||||||
BaseDialog: typeof BaseDialog;
|
BaseDialog: typeof BaseDialog;
|
||||||
BaseDivider: typeof BaseDivider;
|
BaseDivider: typeof BaseDivider;
|
||||||
BaseExpansionPanels: typeof BaseExpansionPanels;
|
BaseExpansionPanels: typeof BaseExpansionPanels;
|
||||||
|
BaseKeyValueEditor: typeof BaseKeyValueEditor;
|
||||||
BaseOverflowButton: typeof BaseOverflowButton;
|
BaseOverflowButton: typeof BaseOverflowButton;
|
||||||
BasePageTitle: typeof BasePageTitle;
|
BasePageTitle: typeof BasePageTitle;
|
||||||
ButtonLink: typeof ButtonLink;
|
ButtonLink: typeof ButtonLink;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mealie",
|
"name": "mealie",
|
||||||
"version": "3.17.0",
|
"version": "3.18.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from sqlalchemy.orm import Session, load_only
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
from mealie.db.models._model_utils.guid import GUID
|
from mealie.db.models._model_utils.guid import GUID
|
||||||
from mealie.db.models.labels import MultiPurposeLabel
|
from mealie.db.models.recipe.labels import MultiPurposeLabel
|
||||||
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel, RecipeIngredientModel
|
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel, RecipeIngredientModel
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"""add table for ai providers
|
||||||
|
|
||||||
|
Revision ID: 2187537c52b8
|
||||||
|
Revises: c7427796f7b6
|
||||||
|
Create Date: 2026-05-18 16:27:05.770218
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
import mealie.db.migration_types
|
||||||
|
from mealie.core.config import ENV
|
||||||
|
from mealie.core.root_logger import get_logger
|
||||||
|
from mealie.core.settings.settings import get_secrets_dir
|
||||||
|
from mealie.db.models._model_utils.guid import GUID
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "2187537c52b8"
|
||||||
|
down_revision: str | None = "c7427796f7b6"
|
||||||
|
branch_labels: str | tuple[str, ...] | None = None
|
||||||
|
depends_on: str | tuple[str, ...] | None = None
|
||||||
|
|
||||||
|
logger = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyOpenAISettings(BaseSettings):
|
||||||
|
OPENAI_BASE_URL: str | None = None
|
||||||
|
OPENAI_API_KEY: str | None = None
|
||||||
|
OPENAI_MODEL: str = "gpt-4o"
|
||||||
|
OPENAI_AUDIO_MODEL: str = "whisper-1"
|
||||||
|
OPENAI_CUSTOM_HEADERS: dict[str, str] = {}
|
||||||
|
OPENAI_CUSTOM_PARAMS: dict[str, str] = {}
|
||||||
|
OPENAI_ENABLE_IMAGE_SERVICES: bool = True
|
||||||
|
OPENAI_ENABLE_TRANSCRIPTION_SERVICES: bool = True
|
||||||
|
OPENAI_REQUEST_TIMEOUT: int = 300
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
def get_openai_settings() -> LegacyOpenAISettings:
|
||||||
|
|
||||||
|
return LegacyOpenAISettings(
|
||||||
|
_env_file=ENV,
|
||||||
|
_env_file_encoding="utf-8",
|
||||||
|
_secrets_dir=get_secrets_dir(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_postgres() -> bool:
|
||||||
|
return op.get_context().dialect.name == "postgresql"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_id() -> str:
|
||||||
|
val = GUID.generate()
|
||||||
|
dialect = op.get_bind().dialect
|
||||||
|
|
||||||
|
return GUID.convert_value_to_guid(val, dialect) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def create_provider(settings_id: str, provider_id: str, model: str, openai_settings: LegacyOpenAISettings) -> None:
|
||||||
|
logger.info(f"Creating provider '{model}'")
|
||||||
|
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO ai_providers (id, settings_id, name, base_url, api_key, model, timeout) "
|
||||||
|
"VALUES (:id, :settings_id, :name, :base_url, :api_key, :model, :timeout)"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": provider_id,
|
||||||
|
"settings_id": settings_id,
|
||||||
|
# we only create one provider per model in this mirgration script,
|
||||||
|
# so there's no chance for collisions
|
||||||
|
"name": model,
|
||||||
|
"base_url": openai_settings.OPENAI_BASE_URL,
|
||||||
|
"api_key": openai_settings.OPENAI_API_KEY,
|
||||||
|
"model": model,
|
||||||
|
"timeout": openai_settings.OPENAI_REQUEST_TIMEOUT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, value in openai_settings.OPENAI_CUSTOM_HEADERS.items():
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO ai_provider_headers (provider_id, key_name, value) "
|
||||||
|
"VALUES (:provider_id, :key_name, :value)"
|
||||||
|
),
|
||||||
|
{"provider_id": provider_id, "key_name": key, "value": value},
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, value in openai_settings.OPENAI_CUSTOM_PARAMS.items():
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO ai_provider_params (provider_id, key_name, value) VALUES (:provider_id, :key_name, :value)"
|
||||||
|
),
|
||||||
|
{"provider_id": provider_id, "key_name": key, "value": value},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_providers() -> None:
|
||||||
|
"""Create provider settings and migrate legacy OPEN_AI_... environment variables to a provider"""
|
||||||
|
|
||||||
|
openai_settings = get_openai_settings()
|
||||||
|
create_providers = bool(openai_settings.OPENAI_API_KEY and openai_settings.OPENAI_MODEL)
|
||||||
|
|
||||||
|
if create_providers:
|
||||||
|
logger.info("Found legacy OpenAI configuration, creating new AI providers")
|
||||||
|
|
||||||
|
conn = op.get_bind()
|
||||||
|
groups = conn.execute(sa.text("SELECT id FROM groups")).fetchall()
|
||||||
|
for (group_id,) in groups:
|
||||||
|
logger.info(f"Creating provider settings for {group_id=}")
|
||||||
|
|
||||||
|
# Create AI provider settings
|
||||||
|
settings_id = generate_id()
|
||||||
|
conn.execute(
|
||||||
|
sa.text("INSERT INTO ai_provider_settings (id, group_id) VALUES (:id, :group_id)"),
|
||||||
|
{"id": settings_id, "group_id": group_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not create_providers:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create provider
|
||||||
|
default_provider_id = generate_id()
|
||||||
|
model = openai_settings.OPENAI_MODEL
|
||||||
|
create_provider(settings_id, default_provider_id, model, openai_settings)
|
||||||
|
|
||||||
|
# Set the image provider if image services are enabled
|
||||||
|
if openai_settings.OPENAI_ENABLE_IMAGE_SERVICES:
|
||||||
|
image_provider_id = default_provider_id
|
||||||
|
else:
|
||||||
|
image_provider_id = None
|
||||||
|
|
||||||
|
# Set the audio provider if transcription services are enabled
|
||||||
|
if openai_settings.OPENAI_ENABLE_TRANSCRIPTION_SERVICES:
|
||||||
|
transcription_model = openai_settings.OPENAI_AUDIO_MODEL or model
|
||||||
|
if transcription_model == openai_settings.OPENAI_MODEL:
|
||||||
|
audio_provider_id = default_provider_id
|
||||||
|
else:
|
||||||
|
# The transcription model is different than the base model, so create a new provider
|
||||||
|
audio_provider_id = generate_id()
|
||||||
|
create_provider(settings_id, audio_provider_id, transcription_model, openai_settings)
|
||||||
|
else:
|
||||||
|
audio_provider_id = None
|
||||||
|
|
||||||
|
# Update the provider settings to reference new provider(s)
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE ai_provider_settings
|
||||||
|
SET
|
||||||
|
default_provider_id = :default_provider_id,
|
||||||
|
audio_provider_id = :audio_provider_id,
|
||||||
|
image_provider_id = :image_provider_id
|
||||||
|
WHERE id = :id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"default_provider_id": default_provider_id,
|
||||||
|
"audio_provider_id": audio_provider_id,
|
||||||
|
"image_provider_id": image_provider_id,
|
||||||
|
"id": settings_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"ai_provider_settings",
|
||||||
|
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("group_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("default_provider_id", mealie.db.migration_types.GUID(), nullable=True),
|
||||||
|
sa.Column("audio_provider_id", mealie.db.migration_types.GUID(), nullable=True),
|
||||||
|
sa.Column("image_provider_id", mealie.db.migration_types.GUID(), 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(["default_provider_id"], ["ai_providers.id"], use_alter=True),
|
||||||
|
sa.ForeignKeyConstraint(["audio_provider_id"], ["ai_providers.id"], use_alter=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["group_id"],
|
||||||
|
["groups.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["image_provider_id"], ["ai_providers.id"], use_alter=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("group_id", name="ai_provider_settings_group_id_key"),
|
||||||
|
)
|
||||||
|
with op.batch_alter_table("ai_provider_settings", schema=None) as batch_op:
|
||||||
|
batch_op.create_index(
|
||||||
|
batch_op.f("ix_ai_provider_settings_default_provider_id"), ["default_provider_id"], unique=False
|
||||||
|
)
|
||||||
|
batch_op.create_index(
|
||||||
|
batch_op.f("ix_ai_provider_settings_audio_provider_id"), ["audio_provider_id"], unique=False
|
||||||
|
)
|
||||||
|
batch_op.create_index(batch_op.f("ix_ai_provider_settings_created_at"), ["created_at"], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f("ix_ai_provider_settings_group_id"), ["group_id"], unique=False)
|
||||||
|
batch_op.create_index(
|
||||||
|
batch_op.f("ix_ai_provider_settings_image_provider_id"), ["image_provider_id"], unique=False
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"ai_providers",
|
||||||
|
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("settings_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
sa.Column("base_url", sa.String(), nullable=True),
|
||||||
|
sa.Column("api_key", sa.String(), nullable=False),
|
||||||
|
sa.Column("model", sa.String(), nullable=False),
|
||||||
|
sa.Column("timeout", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
|
||||||
|
sa.Column("update_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["settings_id"],
|
||||||
|
["ai_provider_settings.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("name", "settings_id", name="ai_providers_name_settings_id_key"),
|
||||||
|
)
|
||||||
|
with op.batch_alter_table("ai_providers", schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f("ix_ai_providers_created_at"), ["created_at"], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f("ix_ai_providers_name"), ["name"], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f("ix_ai_providers_settings_id"), ["settings_id"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"ai_provider_headers",
|
||||||
|
sa.Column("provider_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("key_name", sa.String(), nullable=True),
|
||||||
|
sa.Column("value", sa.String(), 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(
|
||||||
|
["provider_id"],
|
||||||
|
["ai_providers.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
with op.batch_alter_table("ai_provider_headers", schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f("ix_ai_provider_headers_created_at"), ["created_at"], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f("ix_ai_provider_headers_provider_id"), ["provider_id"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"ai_provider_params",
|
||||||
|
sa.Column("provider_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("key_name", sa.String(), nullable=True),
|
||||||
|
sa.Column("value", sa.String(), 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(
|
||||||
|
["provider_id"],
|
||||||
|
["ai_providers.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
with op.batch_alter_table("ai_provider_params", schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f("ix_ai_provider_params_created_at"), ["created_at"], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f("ix_ai_provider_params_provider_id"), ["provider_id"], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
try:
|
||||||
|
with op.get_bind().begin_nested():
|
||||||
|
create_providers()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to migrate legacy OpenAI config")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table("ai_provider_params", schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f("ix_ai_provider_params_provider_id"))
|
||||||
|
batch_op.drop_index(batch_op.f("ix_ai_provider_params_created_at"))
|
||||||
|
|
||||||
|
op.drop_table("ai_provider_params")
|
||||||
|
with op.batch_alter_table("ai_provider_headers", schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f("ix_ai_provider_headers_provider_id"))
|
||||||
|
batch_op.drop_index(batch_op.f("ix_ai_provider_headers_created_at"))
|
||||||
|
|
||||||
|
op.drop_table("ai_provider_headers")
|
||||||
|
with op.batch_alter_table("ai_providers", schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f("ix_ai_providers_settings_id"))
|
||||||
|
batch_op.drop_index(batch_op.f("ix_ai_providers_name"))
|
||||||
|
batch_op.drop_index(batch_op.f("ix_ai_providers_created_at"))
|
||||||
|
|
||||||
|
op.drop_table("ai_providers")
|
||||||
|
with op.batch_alter_table("ai_provider_settings", schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f("ix_ai_provider_settings_image_provider_id"))
|
||||||
|
batch_op.drop_index(batch_op.f("ix_ai_provider_settings_group_id"))
|
||||||
|
batch_op.drop_index(batch_op.f("ix_ai_provider_settings_created_at"))
|
||||||
|
batch_op.drop_index(batch_op.f("ix_ai_provider_settings_audio_provider_id"))
|
||||||
|
batch_op.drop_index(batch_op.f("ix_ai_provider_settings_default_provider_id"))
|
||||||
|
|
||||||
|
op.drop_table("ai_provider_settings")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -88,8 +88,6 @@ async def lifespan_fn(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
logger.info(settings.LDAP_FEATURE)
|
logger.info(settings.LDAP_FEATURE)
|
||||||
logger.info("--------==OIDC==--------")
|
logger.info("--------==OIDC==--------")
|
||||||
logger.info(settings.OIDC_FEATURE)
|
logger.info(settings.OIDC_FEATURE)
|
||||||
logger.info("-------==OPENAI==-------")
|
|
||||||
logger.info(settings.OPENAI_FEATURE)
|
|
||||||
logger.info("------------------------")
|
logger.info("------------------------")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class OpenIDProvider(AuthProvider[UserInfo]):
|
|||||||
self._logger.debug("[OIDC] %s: %s", key, value)
|
self._logger.debug("[OIDC] %s: %s", key, value)
|
||||||
|
|
||||||
if not self.required_claims.issubset(claims.keys()):
|
if not self.required_claims.issubset(claims.keys()):
|
||||||
self._logger.error(
|
self._logger.debug(
|
||||||
"[OIDC] Required claims not present. Expected: %s Actual: %s",
|
"[OIDC] Required claims not present. Expected: %s Actual: %s",
|
||||||
self.required_claims,
|
self.required_claims,
|
||||||
claims.keys(),
|
claims.keys(),
|
||||||
@@ -45,7 +45,7 @@ class OpenIDProvider(AuthProvider[UserInfo]):
|
|||||||
# Check for empty required claims
|
# Check for empty required claims
|
||||||
for claim in self.required_claims:
|
for claim in self.required_claims:
|
||||||
if not claims.get(claim):
|
if not claims.get(claim):
|
||||||
self._logger.error("[OIDC] Required claim '%s' is empty", claim)
|
self._logger.debug("[OIDC] Required claim '%s' is empty", claim)
|
||||||
raise MissingClaimException()
|
raise MissingClaimException()
|
||||||
|
|
||||||
repos = get_repositories(self.session, group_id=None, household_id=None)
|
repos = get_repositories(self.session, group_id=None, household_id=None)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import os
|
|||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, Literal, NamedTuple
|
from typing import Annotated, Literal, NamedTuple
|
||||||
|
|
||||||
from dateutil.tz import tzlocal
|
from dateutil.tz import tzlocal
|
||||||
from pydantic import PlainSerializer, field_validator
|
from pydantic import PlainSerializer, field_validator
|
||||||
@@ -388,60 +388,12 @@ class AppSettings(AppLoggingSettings):
|
|||||||
# ===============================================
|
# ===============================================
|
||||||
# OpenAI Configuration
|
# OpenAI Configuration
|
||||||
|
|
||||||
OPENAI_BASE_URL: str | None = None
|
|
||||||
"""The base URL for the OpenAI API. Leave this unset for most usecases"""
|
|
||||||
OPENAI_API_KEY: MaskedNoneString = None
|
|
||||||
"""Your OpenAI API key. Required to enable OpenAI features"""
|
|
||||||
OPENAI_MODEL: str = "gpt-4o"
|
|
||||||
"""Which OpenAI model to send requests to. Leave this unset for most usecases"""
|
|
||||||
OPENAI_AUDIO_MODEL: str = "whisper-1"
|
|
||||||
"""Which OpenAI model to use for audio transcription. Leave this unset for most usecases"""
|
|
||||||
OPENAI_CUSTOM_HEADERS: dict[str, str] = {}
|
|
||||||
"""Custom HTTP headers to send with each OpenAI request"""
|
|
||||||
OPENAI_CUSTOM_PARAMS: dict[str, Any] = {}
|
|
||||||
"""Custom HTTP parameters to send with each OpenAI request"""
|
|
||||||
OPENAI_ENABLE_IMAGE_SERVICES: bool = True
|
|
||||||
"""Whether to enable image-related features in OpenAI"""
|
|
||||||
OPENAI_ENABLE_TRANSCRIPTION_SERVICES: bool = True
|
|
||||||
"""Whether to enable audio transcription features in OpenAI"""
|
|
||||||
OPENAI_WORKERS: int = 2
|
|
||||||
"""
|
|
||||||
Number of OpenAI workers per request. Higher values may increase
|
|
||||||
processing speed, but will incur additional API costs
|
|
||||||
"""
|
|
||||||
OPENAI_SEND_DATABASE_DATA: bool = True
|
|
||||||
"""
|
|
||||||
Sending database data may increase accuracy in certain requests,
|
|
||||||
but will incur additional API costs
|
|
||||||
"""
|
|
||||||
OPENAI_REQUEST_TIMEOUT: int = 300
|
|
||||||
"""
|
|
||||||
The number of seconds to wait for an OpenAI request to complete before cancelling the request
|
|
||||||
"""
|
|
||||||
OPENAI_CUSTOM_PROMPT_DIR: str | None = None
|
OPENAI_CUSTOM_PROMPT_DIR: str | None = None
|
||||||
"""
|
"""
|
||||||
Path to a folder containing custom prompt files;
|
Path to a folder containing custom prompt files;
|
||||||
files are individually optional, each prompt name will fall back to the default if no custom file exists
|
files are individually optional, each prompt name will fall back to the default if no custom file exists
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
|
||||||
def OPENAI_FEATURE(self) -> FeatureDetails:
|
|
||||||
description = None
|
|
||||||
if not self.OPENAI_API_KEY:
|
|
||||||
description = "OPENAI_API_KEY is not set"
|
|
||||||
elif not self.OPENAI_MODEL:
|
|
||||||
description = "OPENAI_MODEL is not set"
|
|
||||||
|
|
||||||
return FeatureDetails(
|
|
||||||
enabled=bool(self.OPENAI_API_KEY and self.OPENAI_MODEL),
|
|
||||||
description=description,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def OPENAI_ENABLED(self) -> bool:
|
|
||||||
"""Validates OpenAI settings are all set"""
|
|
||||||
return self.OPENAI_FEATURE.enabled
|
|
||||||
|
|
||||||
# ===============================================
|
# ===============================================
|
||||||
# Web Concurrency
|
# Web Concurrency
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ from mealie.core import root_logger
|
|||||||
from mealie.db.models._model_base import SqlAlchemyBase
|
from mealie.db.models._model_base import SqlAlchemyBase
|
||||||
from mealie.db.models.group.group import Group
|
from mealie.db.models.group.group import Group
|
||||||
from mealie.db.models.household.shopping_list import ShoppingList, ShoppingListMultiPurposeLabel
|
from mealie.db.models.household.shopping_list import ShoppingList, ShoppingListMultiPurposeLabel
|
||||||
from mealie.db.models.labels import MultiPurposeLabel
|
|
||||||
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
|
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
|
||||||
|
from mealie.db.models.recipe.labels import MultiPurposeLabel
|
||||||
from mealie.db.models.recipe.recipe import RecipeModel
|
from mealie.db.models.recipe.recipe import RecipeModel
|
||||||
from mealie.db.models.users.users import User
|
from mealie.db.models.users.users import User
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from .group import *
|
from .group import *
|
||||||
from .labels import *
|
|
||||||
from .recipe import *
|
from .recipe import *
|
||||||
from .server import *
|
from .server import *
|
||||||
from .users import *
|
from .users import *
|
||||||
|
|||||||
23
mealie/db/models/_filterable_column.py
Normal file
23
mealie/db/models/_filterable_column.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from typing import TYPE_CHECKING, Annotated
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class _FilterableColumn[T]:
|
||||||
|
"""
|
||||||
|
Drop-in replacement for `Mapped[]` that marks a column as filterable.
|
||||||
|
Filterable columns can be used in query filter expressions.
|
||||||
|
|
||||||
|
Only valid on scalar column fields. Using it on a relationship type (e.g. `list[Model]`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __class_getitem__(cls, item: type) -> type:
|
||||||
|
return Mapped[Annotated[item, mapped_column(info={"filterable": True})]]
|
||||||
|
|
||||||
|
|
||||||
|
# SQLAlchemy doesn't play nice with mypy when overriding Mapped, so
|
||||||
|
# we use this awkward workaround to make mypy happy
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
FilterableColumn = Mapped
|
||||||
|
else:
|
||||||
|
FilterableColumn = _FilterableColumn
|
||||||
@@ -5,6 +5,7 @@ from sqlalchemy import Integer
|
|||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, synonym
|
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, synonym
|
||||||
from text_unidecode import unidecode
|
from text_unidecode import unidecode
|
||||||
|
|
||||||
|
from ._filterable_column import FilterableColumn
|
||||||
from ._model_utils.datetime import NaiveDateTime, get_utc_now
|
from ._model_utils.datetime import NaiveDateTime, get_utc_now
|
||||||
|
|
||||||
# Punctuation characters replaced with spaces during text normalization.
|
# Punctuation characters replaced with spaces during text normalization.
|
||||||
@@ -16,8 +17,10 @@ _NORMALIZE_PUNCTUATION_TABLE = str.maketrans(NORMALIZE_PUNCTUATION, " " * len(NO
|
|||||||
|
|
||||||
class SqlAlchemyBase(DeclarativeBase):
|
class SqlAlchemyBase(DeclarativeBase):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
created_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, index=True)
|
created_at: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, index=True)
|
||||||
update_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, onupdate=get_utc_now)
|
update_at: FilterableColumn[datetime | None] = mapped_column(
|
||||||
|
NaiveDateTime, default=get_utc_now, onupdate=get_utc_now
|
||||||
|
)
|
||||||
|
|
||||||
@declared_attr
|
@declared_attr
|
||||||
def updated_at(cls) -> Mapped[datetime | None]:
|
def updated_at(cls) -> Mapped[datetime | None]:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from .ai_providers import *
|
||||||
from .exports import *
|
from .exports import *
|
||||||
from .group import *
|
from .group import *
|
||||||
from .preferences import *
|
from .preferences import *
|
||||||
|
|||||||
145
mealie/db/models/group/ai_providers.py
Normal file
145
mealie/db/models/group/ai_providers.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import orm
|
||||||
|
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
|
||||||
|
|
||||||
|
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||||
|
from .._model_utils.auto_init import auto_init
|
||||||
|
from .._model_utils.guid import GUID
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .group import Group
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap_headers_and_params(func):
|
||||||
|
"""Decorator function to unpack headers and params into dicts"""
|
||||||
|
|
||||||
|
def unwrap(value: dict | None) -> list[dict]:
|
||||||
|
if value is None:
|
||||||
|
value = {}
|
||||||
|
|
||||||
|
return [{"key": k, "value": v} for k, v in value.items()]
|
||||||
|
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
headers = kwargs.pop("request_headers", {})
|
||||||
|
params = kwargs.pop("request_params", {})
|
||||||
|
|
||||||
|
return func(
|
||||||
|
*args,
|
||||||
|
request_headers=unwrap(headers),
|
||||||
|
request_params=unwrap(params),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class AIProviderKV:
|
||||||
|
"""
|
||||||
|
Template for key-value pairs
|
||||||
|
|
||||||
|
This class is not an actual table, so it does not inherit from SqlAlchemyBase
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: orm.Mapped[int] = orm.mapped_column(sa.Integer, primary_key=True)
|
||||||
|
key_name: orm.Mapped[str | None] = orm.mapped_column(sa.String)
|
||||||
|
value: orm.Mapped[str | None] = orm.mapped_column(sa.String)
|
||||||
|
|
||||||
|
def __init__(self, key, value) -> None:
|
||||||
|
self.key_name = key
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
class AIProviderHeaders(AIProviderKV, SqlAlchemyBase):
|
||||||
|
__tablename__ = "ai_provider_headers"
|
||||||
|
provider_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("ai_providers.id"), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AIProviderParams(AIProviderKV, SqlAlchemyBase):
|
||||||
|
__tablename__ = "ai_provider_params"
|
||||||
|
provider_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("ai_providers.id"), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AIProvider(SqlAlchemyBase, BaseMixins):
|
||||||
|
__tablename__ = "ai_providers"
|
||||||
|
__table_args__ = (sa.UniqueConstraint("name", "settings_id", name="ai_providers_name_settings_id_key"),)
|
||||||
|
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||||
|
group_id: AssociationProxy[GUID] = association_proxy("settings", "group_id")
|
||||||
|
|
||||||
|
settings_id: orm.Mapped[GUID] = orm.mapped_column(
|
||||||
|
GUID, sa.ForeignKey("ai_provider_settings.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
settings: orm.Mapped["AIProviderSettings"] = orm.relationship(
|
||||||
|
"AIProviderSettings", foreign_keys="[AIProvider.settings_id]", back_populates="providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
name: orm.Mapped[str] = orm.mapped_column(sa.String, index=True, nullable=False)
|
||||||
|
base_url: orm.Mapped[str | None] = orm.mapped_column(sa.String, nullable=True)
|
||||||
|
api_key: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False)
|
||||||
|
model: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False)
|
||||||
|
timeout: orm.Mapped[int] = orm.mapped_column(sa.Integer, nullable=False, default=300)
|
||||||
|
|
||||||
|
request_headers: orm.Mapped[list[AIProviderHeaders]] = orm.relationship(
|
||||||
|
"AIProviderHeaders", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
request_params: orm.Mapped[list[AIProviderParams]] = orm.relationship(
|
||||||
|
"AIProviderParams", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
@unwrap_headers_and_params
|
||||||
|
@auto_init()
|
||||||
|
def __init__(self, **_) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AIProviderSettings(SqlAlchemyBase, BaseMixins):
|
||||||
|
__tablename__ = "ai_provider_settings"
|
||||||
|
__table_args__ = (sa.UniqueConstraint("group_id", name="ai_provider_settings_group_id_key"),)
|
||||||
|
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)
|
||||||
|
group: orm.Mapped["Group"] = orm.relationship("Group", back_populates="ai_provider_settings")
|
||||||
|
providers: orm.Mapped[list[AIProvider]] = orm.relationship(
|
||||||
|
AIProvider,
|
||||||
|
foreign_keys="[AIProvider.settings_id]",
|
||||||
|
back_populates="settings",
|
||||||
|
uselist=True,
|
||||||
|
single_parent=True,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configured Providers
|
||||||
|
default_provider_id: orm.Mapped[GUID | None] = orm.mapped_column(
|
||||||
|
GUID, sa.ForeignKey("ai_providers.id", use_alter=True), nullable=True, index=True
|
||||||
|
)
|
||||||
|
default_provider: orm.Mapped[AIProvider | None] = orm.relationship(
|
||||||
|
AIProvider,
|
||||||
|
foreign_keys="[AIProviderSettings.default_provider_id]",
|
||||||
|
uselist=False,
|
||||||
|
post_update=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
audio_provider_id: orm.Mapped[GUID | None] = orm.mapped_column(
|
||||||
|
GUID, sa.ForeignKey("ai_providers.id", use_alter=True), nullable=True, index=True
|
||||||
|
)
|
||||||
|
audio_provider: orm.Mapped[AIProvider | None] = orm.relationship(
|
||||||
|
AIProvider,
|
||||||
|
foreign_keys="[AIProviderSettings.audio_provider_id]",
|
||||||
|
uselist=False,
|
||||||
|
post_update=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
image_provider_id: orm.Mapped[GUID | None] = orm.mapped_column(
|
||||||
|
GUID, sa.ForeignKey("ai_providers.id", use_alter=True), nullable=True, index=True
|
||||||
|
)
|
||||||
|
image_provider: orm.Mapped[AIProvider | None] = orm.relationship(
|
||||||
|
AIProvider,
|
||||||
|
foreign_keys="[AIProviderSettings.image_provider_id]",
|
||||||
|
uselist=False,
|
||||||
|
post_update=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@auto_init()
|
||||||
|
def __init__(self, **_) -> None:
|
||||||
|
pass
|
||||||
@@ -5,9 +5,9 @@ import sqlalchemy.orm as orm
|
|||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from mealie.db.models.labels import MultiPurposeLabel
|
from mealie.db.models.recipe.labels import MultiPurposeLabel
|
||||||
|
|
||||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
from .._model_base import BaseMixins, FilterableColumn, 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 ..household.cookbook import CookBook
|
from ..household.cookbook import CookBook
|
||||||
@@ -16,6 +16,7 @@ from ..household.mealplan import GroupMealPlan
|
|||||||
from ..household.webhooks import GroupWebhooksModel
|
from ..household.webhooks import GroupWebhooksModel
|
||||||
from ..recipe.category import Category, group_to_categories
|
from ..recipe.category import Category, group_to_categories
|
||||||
from ..server.task import ServerTaskModel
|
from ..server.task import ServerTaskModel
|
||||||
|
from .ai_providers import AIProviderSettings
|
||||||
from .preferences import GroupPreferencesModel
|
from .preferences import GroupPreferencesModel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -31,9 +32,9 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class Group(SqlAlchemyBase, BaseMixins):
|
class Group(SqlAlchemyBase, BaseMixins):
|
||||||
__tablename__ = "groups"
|
__tablename__ = "groups"
|
||||||
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||||
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
|
name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
|
||||||
slug: Mapped[str | None] = mapped_column(sa.String, index=True, unique=True)
|
slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True, unique=True)
|
||||||
households: Mapped[list["Household"]] = orm.relationship("Household", back_populates="group")
|
households: Mapped[list["Household"]] = orm.relationship("Household", back_populates="group")
|
||||||
users: Mapped[list["User"]] = orm.relationship("User", back_populates="group")
|
users: Mapped[list["User"]] = orm.relationship("User", back_populates="group")
|
||||||
categories: Mapped[list[Category]] = orm.relationship(Category, secondary=group_to_categories, single_parent=True)
|
categories: Mapped[list[Category]] = orm.relationship(Category, secondary=group_to_categories, single_parent=True)
|
||||||
@@ -41,6 +42,8 @@ class Group(SqlAlchemyBase, BaseMixins):
|
|||||||
invite_tokens: Mapped[list[GroupInviteToken]] = orm.relationship(
|
invite_tokens: Mapped[list[GroupInviteToken]] = orm.relationship(
|
||||||
GroupInviteToken, back_populates="group", cascade="all, delete-orphan"
|
GroupInviteToken, back_populates="group", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Config
|
||||||
preferences: Mapped[GroupPreferencesModel] = orm.relationship(
|
preferences: Mapped[GroupPreferencesModel] = orm.relationship(
|
||||||
GroupPreferencesModel,
|
GroupPreferencesModel,
|
||||||
back_populates="group",
|
back_populates="group",
|
||||||
@@ -48,6 +51,13 @@ class Group(SqlAlchemyBase, BaseMixins):
|
|||||||
single_parent=True,
|
single_parent=True,
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
ai_provider_settings: Mapped[AIProviderSettings] = orm.relationship(
|
||||||
|
AIProviderSettings,
|
||||||
|
back_populates="group",
|
||||||
|
uselist=False,
|
||||||
|
single_parent=True,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
# Recipes
|
# Recipes
|
||||||
recipes: Mapped[list["RecipeModel"]] = orm.relationship("RecipeModel", back_populates="group")
|
recipes: Mapped[list["RecipeModel"]] = orm.relationship("RecipeModel", back_populates="group")
|
||||||
@@ -89,6 +99,7 @@ class Group(SqlAlchemyBase, BaseMixins):
|
|||||||
"shopping_lists",
|
"shopping_lists",
|
||||||
"cookbooks",
|
"cookbooks",
|
||||||
"preferences",
|
"preferences",
|
||||||
|
"ai_provider_settings",
|
||||||
"invite_tokens",
|
"invite_tokens",
|
||||||
"mealplans",
|
"mealplans",
|
||||||
"data_exports",
|
"data_exports",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user