feat: Import + Translate recipe images with OpenAI (#3974)

Co-authored-by: Johan Lindell <johan@lindell.me>
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
This commit is contained in:
Michael Genson 2024-08-17 17:07:01 -05:00 committed by GitHub
parent 3d921cb677
commit 8a15f400e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 924 additions and 241 deletions

View File

@ -17,9 +17,16 @@ class PathObject(BaseModel):
http_verbs: list[HTTPRequest]
def get_path_objects(app: FastAPI):
paths = []
def force_include_in_schema(app: FastAPI):
# clear schema cache
app.openapi_schema = None
for route in app.routes:
route.include_in_schema = True
def get_path_objects(app: FastAPI):
force_include_in_schema(app)
paths = []
for key, value in app.openapi().items():
if key == "paths":
for key, value2 in value.items():

View File

@ -106,14 +106,15 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc.md)
Mealie supports various integrations using OpenAI. To enable OpenAI, [you must provide your OpenAI API key](https://platform.openai.com/api-keys). You can tweak how OpenAI is used using these backend settings. Please note that while OpenAI usage is optimized to reduce API costs, you're unlikely to be able to use OpenAI features with the free tier limits.
| Variables | Default | Description |
| ------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OPENAI_BASE_URL | 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 | 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_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 | 10 | 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 |
| Variables | Default | Description |
| ---------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OPENAI_BASE_URL | 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 | 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_ENABLE_IMAGE_SERVICES | 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_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 | 60 | 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 |
### Themeing

View File

@ -1,25 +1,26 @@
<template>
<v-app dark>
<TheSnackbar />
<v-app dark>
<TheSnackbar />
<AppSidebar
v-model="sidebar"
absolute
:top-link="topLinks"
:secondary-links="cookbookLinks || []"
:bottom-links="isAdmin ? bottomLinks : []"
>
<v-menu offset-y nudge-bottom="5" close-delay="50" nudge-right="15">
<template #activator="{ on, attrs }">
<v-btn v-if="isOwnGroup" rounded large class="ml-2 mt-3" v-bind="attrs" v-on="on">
<v-icon left large color="primary">
{{ $globals.icons.createAlt }}
</v-icon>
{{ $t("general.create") }}
</v-btn>
</template>
<v-list dense class="my-0 py-0">
<template v-for="(item, index) in createLinks">
<AppSidebar
v-model="sidebar"
absolute
:top-link="topLinks"
:secondary-links="cookbookLinks || []"
:bottom-links="isAdmin ? bottomLinks : []"
>
<v-menu offset-y nudge-bottom="5" close-delay="50" nudge-right="15">
<template #activator="{ on, attrs }">
<v-btn v-if="isOwnGroup" rounded large class="ml-2 mt-3" v-bind="attrs" v-on="on">
<v-icon left large color="primary">
{{ $globals.icons.createAlt }}
</v-icon>
{{ $t("general.create") }}
</v-btn>
</template>
<v-list dense class="my-0 py-0">
<template v-for="(item, index) in createLinks">
<div v-if="!item.hide" :key="item.title">
<v-divider v-if="item.insertDivider" :key="index" class="mx-2"></v-divider>
<v-list-item v-if="!item.restricted || isOwnGroup" :key="item.title" :to="item.to" exact>
<v-list-item-avatar>
@ -36,195 +37,212 @@
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
</v-list>
</v-menu>
<template #bottom>
<v-list-item @click.stop="languageDialog = true">
<v-list-item-icon>
<v-icon>{{ $globals.icons.translate }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
<LanguageDialog v-model="languageDialog" />
</v-list-item-content>
</v-list-item>
<v-list-item @click="toggleDark">
<v-list-item-icon>
<v-icon>
{{ $vuetify.theme.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
</v-icon>
</v-list-item-icon>
<v-list-item-title>
{{ $vuetify.theme.dark ? $t("settings.theme.light-mode") : $t("settings.theme.dark-mode") }}
</v-list-item-title>
</v-list-item>
</template>
</AppSidebar>
</div>
</template>
</v-list>
</v-menu>
<template #bottom>
<v-list-item @click.stop="languageDialog = true">
<v-list-item-icon>
<v-icon>{{ $globals.icons.translate }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
<LanguageDialog v-model="languageDialog" />
</v-list-item-content>
</v-list-item>
<v-list-item @click="toggleDark">
<v-list-item-icon>
<v-icon>
{{ $vuetify.theme.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
</v-icon>
</v-list-item-icon>
<v-list-item-title>
{{ $vuetify.theme.dark ? $t("settings.theme.light-mode") : $t("settings.theme.dark-mode") }}
</v-list-item-title>
</v-list-item>
</template>
</AppSidebar>
<AppHeader>
<v-btn icon @click.stop="sidebar = !sidebar">
<v-icon> {{ $globals.icons.menu }}</v-icon>
</v-btn>
</AppHeader>
<v-main>
<v-scroll-x-transition>
<Nuxt />
</v-scroll-x-transition>
</v-main>
</v-app>
</template>
<AppHeader>
<v-btn icon @click.stop="sidebar = !sidebar">
<v-icon> {{ $globals.icons.menu }}</v-icon>
</v-btn>
</AppHeader>
<v-main>
<v-scroll-x-transition>
<Nuxt />
</v-scroll-x-transition>
</v-main>
</v-app>
</template>
<script lang="ts">
import { computed, defineComponent, onMounted, ref, useContext, useRoute } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
import { SidebarLinks } from "~/types/application-types";
import LanguageDialog from "~/components/global/LanguageDialog.vue";
import TheSnackbar from "@/components/Layout/LayoutParts/TheSnackbar.vue";
import { useCookbooks, usePublicCookbooks } from "~/composables/use-group-cookbooks";
import { useToggleDarkMode } from "~/composables/use-utils";
<script lang="ts">
import { computed, defineComponent, onMounted, ref, useContext, useRoute } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
import { SidebarLinks } from "~/types/application-types";
import LanguageDialog from "~/components/global/LanguageDialog.vue";
import TheSnackbar from "@/components/Layout/LayoutParts/TheSnackbar.vue";
import { useAppInfo } from "~/composables/api";
import { useCookbooks, usePublicCookbooks } from "~/composables/use-group-cookbooks";
import { useToggleDarkMode } from "~/composables/use-utils";
export default defineComponent({
components: { AppHeader, AppSidebar, LanguageDialog, TheSnackbar },
setup() {
const { $globals, $auth, $vuetify, i18n } = useContext();
const { isOwnGroup } = useLoggedInState();
export default defineComponent({
components: { AppHeader, AppSidebar, LanguageDialog, TheSnackbar },
setup() {
const { $globals, $auth, $vuetify, i18n } = useContext();
const { isOwnGroup } = useLoggedInState();
const isAdmin = computed(() => $auth.user?.admin);
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const { cookbooks } = isOwnGroup.value ? useCookbooks() : usePublicCookbooks(groupSlug.value || "");
const isAdmin = computed(() => $auth.user?.admin);
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const { cookbooks } = isOwnGroup.value ? useCookbooks() : usePublicCookbooks(groupSlug.value || "");
const toggleDark = useToggleDarkMode();
const appInfo = useAppInfo();
const showImageImport = computed(() => appInfo.value?.enableOpenaiImageServices);
const languageDialog = ref<boolean>(false);
const toggleDark = useToggleDarkMode();
const sidebar = ref<boolean | null>(null);
const languageDialog = ref<boolean>(false);
onMounted(() => {
sidebar.value = !$vuetify.breakpoint.md;
const sidebar = ref<boolean | null>(null);
onMounted(() => {
sidebar.value = !$vuetify.breakpoint.md;
});
const cookbookLinks = computed(() => {
if (!cookbooks.value) return [];
return cookbooks.value.map((cookbook) => {
return {
icon: $globals.icons.pages,
title: cookbook.name,
to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug as string}`,
};
});
});
const cookbookLinks = computed(() => {
if (!cookbooks.value) return [];
return cookbooks.value.map((cookbook) => {
return {
icon: $globals.icons.pages,
title: cookbook.name,
to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug as string}`,
};
});
});
interface Link {
insertDivider: boolean;
icon: string;
title: string;
subtitle: string | null;
to: string;
restricted: boolean;
hide: boolean;
}
interface Link {
insertDivider: boolean;
icon: string;
title: string;
subtitle: string | null;
to: string;
restricted: boolean;
}
const createLinks = computed<Link[]>(() => [
{
insertDivider: false,
icon: $globals.icons.link,
title: i18n.tc("general.import"),
subtitle: i18n.tc("new-recipe.import-by-url"),
to: `/g/${groupSlug.value}/r/create/url`,
restricted: true,
hide: false,
},
{
insertDivider: false,
icon: $globals.icons.fileImage,
title: i18n.tc("recipe.create-from-image"),
subtitle: i18n.tc("recipe.create-recipe-from-an-image"),
to: `/g/${groupSlug.value}/r/create/image`,
restricted: true,
hide: !showImageImport.value,
},
{
insertDivider: true,
icon: $globals.icons.edit,
title: i18n.tc("general.create"),
subtitle: i18n.tc("new-recipe.create-manually"),
to: `/g/${groupSlug.value}/r/create/new`,
restricted: true,
hide: false,
},
]);
const createLinks = computed<Link[]>(() => [
{
insertDivider: false,
icon: $globals.icons.link,
title: i18n.tc("general.import"),
subtitle: i18n.tc("new-recipe.import-by-url"),
to: `/g/${groupSlug.value}/r/create/url`,
restricted: true,
},
{
insertDivider: true,
icon: $globals.icons.edit,
title: i18n.tc("general.create"),
subtitle: i18n.tc("new-recipe.create-manually"),
to: `/g/${groupSlug.value}/r/create/new`,
restricted: true,
},
]);
const bottomLinks = computed<SidebarLinks>(() => [
{
icon: $globals.icons.cog,
title: i18n.tc("general.settings"),
to: "/admin/site-settings",
restricted: true,
},
]);
const bottomLinks = computed<SidebarLinks>(() => [
{
icon: $globals.icons.cog,
title: i18n.tc("general.settings"),
to: "/admin/site-settings",
restricted: true,
},
]);
const topLinks = computed<SidebarLinks>(() => [
{
icon: $globals.icons.silverwareForkKnife,
to: `/g/${groupSlug.value}`,
title: i18n.tc("general.recipes"),
restricted: true,
},
{
icon: $globals.icons.calendarMultiselect,
title: i18n.tc("meal-plan.meal-planner"),
to: "/group/mealplan/planner/view",
restricted: true,
},
{
icon: $globals.icons.formatListCheck,
title: i18n.tc("shopping-list.shopping-lists"),
to: "/shopping-lists",
restricted: true,
},
{
icon: $globals.icons.timelineText,
title: i18n.tc("recipe.timeline"),
to: `/g/${groupSlug.value}/recipes/timeline`,
restricted: true,
},
{
icon: $globals.icons.book,
to: `/g/${groupSlug.value}/cookbooks`,
title: i18n.tc("cookbook.cookbooks"),
restricted: true,
},
{
icon: $globals.icons.organizers,
title: i18n.tc("general.organizers"),
restricted: true,
children: [
{
icon: $globals.icons.categories,
to: `/g/${groupSlug.value}/recipes/categories`,
title: i18n.tc("sidebar.categories"),
restricted: true,
},
{
icon: $globals.icons.tags,
to: `/g/${groupSlug.value}/recipes/tags`,
title: i18n.tc("sidebar.tags"),
restricted: true,
},
{
icon: $globals.icons.potSteam,
to: `/g/${groupSlug.value}/recipes/tools`,
title: i18n.tc("tool.tools"),
restricted: true,
},
],
},
]);
const topLinks = computed<SidebarLinks>(() => [
{
icon: $globals.icons.silverwareForkKnife,
to: `/g/${groupSlug.value}`,
title: i18n.tc("general.recipes"),
restricted: true,
},
{
icon: $globals.icons.calendarMultiselect,
title: i18n.tc("meal-plan.meal-planner"),
to: "/group/mealplan/planner/view",
restricted: true,
},
{
icon: $globals.icons.formatListCheck,
title: i18n.tc("shopping-list.shopping-lists"),
to: "/shopping-lists",
restricted: true,
},
{
icon: $globals.icons.timelineText,
title: i18n.tc("recipe.timeline"),
to: `/g/${groupSlug.value}/recipes/timeline`,
restricted: true,
},
{
icon: $globals.icons.book,
to: `/g/${groupSlug.value}/cookbooks`,
title: i18n.tc("cookbook.cookbooks"),
restricted: true,
},
{
icon: $globals.icons.organizers,
title: i18n.tc("general.organizers"),
restricted: true,
children: [
{
icon: $globals.icons.categories,
to: `/g/${groupSlug.value}/recipes/categories`,
title: i18n.tc("sidebar.categories"),
restricted: true,
},
{
icon: $globals.icons.tags,
to: `/g/${groupSlug.value}/recipes/tags`,
title: i18n.tc("sidebar.tags"),
restricted: true,
},
{
icon: $globals.icons.potSteam,
to: `/g/${groupSlug.value}/recipes/tools`,
title: i18n.tc("tool.tools"),
restricted: true,
},
],
},
]);
return {
groupSlug,
cookbookLinks,
createLinks,
bottomLinks,
topLinks,
isAdmin,
isOwnGroup,
languageDialog,
toggleDark,
sidebar,
};
},
});
</script>
return {
groupSlug,
cookbookLinks,
createLinks,
bottomLinks,
topLinks,
isAdmin,
isOwnGroup,
languageDialog,
toggleDark,
sidebar,
};
},
});
</script>

View File

@ -1,6 +1,16 @@
<template>
<v-container class="pa-0">
<v-row no-gutters>
<v-col cols="8" align-self="center">
<Cropper
ref="cropper"
class="cropper"
:src="img"
:default-size="defaultSize"
:style="`height: ${cropperHeight}; width: ${cropperWidth};`"
/>
</v-col>
<v-spacer />
<v-col cols="2" align-self="center">
<v-container class="pa-0 mx-0">
<v-row v-for="(row, keyRow) in controls" :key="keyRow">
@ -17,16 +27,6 @@
</v-row>
</v-container>
</v-col>
<v-spacer />
<v-col cols="8" align-self="center">
<Cropper
ref="cropper"
class="cropper"
:src="img"
:default-size="defaultSize"
:style="`height: ${cropperHeight}; width: ${cropperWidth};`"
/>
</v-col>
</v-row>
</v-container>
</template>

View File

@ -561,7 +561,12 @@
"create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes",
"import-with-zip": "Import with .zip",
"create-recipe-from-an-image": "Create recipe from an image",
"create-recipe-from-an-image": "Create Recipe from an Image",
"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.",
"create-from-image": "Create from Image",
"should-translate-description": "Translate the recipe into my language",
"please-wait-image-procesing": "Please wait, the image is processing. This may take some time.",
"bulk-url-import": "Bulk URL Import",
"debug-scraper": "Debug Scraper",
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Create a recipe by providing the name. All recipes must have unique names.",

View File

@ -42,6 +42,7 @@ export interface AppInfo {
oidcRedirect: boolean;
oidcProviderName: string;
enableOpenai: boolean;
enableOpenaiImageServices: boolean;
}
export interface AppStartupInfo {
isFirstLogin: boolean;

View File

@ -35,6 +35,7 @@ const routes = {
recipesCreateUrl: `${prefix}/recipes/create-url`,
recipesCreateUrlBulk: `${prefix}/recipes/create-url/bulk`,
recipesCreateFromZip: `${prefix}/recipes/create-from-zip`,
recipesCreateFromImage: `${prefix}/recipes/create-from-image`,
recipesCategory: `${prefix}/recipes/category`,
recipesParseIngredient: `${prefix}/parser/ingredient`,
recipesParseIngredients: `${prefix}/parser/ingredients`,
@ -140,6 +141,19 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
return await this.requests.post<string>(routes.recipesCreateUrlBulk, payload);
}
async createOneFromImage(fileObject: Blob | File, fileName: string, translateLanguage: string | null = null) {
const formData = new FormData();
formData.append("images", fileObject);
formData.append("extension", fileName.split(".").pop() ?? "");
let apiRoute = routes.recipesCreateFromImage
if (translateLanguage) {
apiRoute = `${apiRoute}?translateLanguage=${translateLanguage}`
}
return await this.requests.post<string>(apiRoute, formData);
}
async parseIngredients(parser: Parser, ingredients: Array<string>) {
parser = parser || "nlp";
return await this.requests.post<ParsedIngredient[]>(routes.recipesParseIngredients, { parser, ingredients });

View File

@ -28,6 +28,7 @@
<script lang="ts">
import { defineComponent, useRouter, useContext, computed, useRoute } from "@nuxtjs/composition-api";
import { useAppInfo } from "~/composables/api";
import { MenuItem } from "~/components/global/BaseOverflowButton.vue";
import AdvancedOnly from "~/components/global/AdvancedOnly.vue";
@ -37,7 +38,10 @@ export default defineComponent({
setup() {
const { $auth, $globals, i18n } = useContext();
const subpages: MenuItem[] = [
const appInfo = useAppInfo();
const enableOpenAIImages = computed(() => appInfo.value?.enableOpenaiImageServices);
const subpages = computed<MenuItem[]>(() => [
{
icon: $globals.icons.link,
text: i18n.tc("recipe.import-with-url"),
@ -48,6 +52,12 @@ export default defineComponent({
text: i18n.tc("recipe.bulk-url-import"),
value: "bulk",
},
{
icon: $globals.icons.fileImage,
text: i18n.tc("recipe.create-from-image"),
value: "image",
hide: !enableOpenAIImages.value,
},
{
icon: $globals.icons.edit,
text: i18n.tc("recipe.create-recipe"),
@ -63,7 +73,7 @@ export default defineComponent({
text: i18n.tc("recipe.debug-scraper"),
value: "debug",
},
];
]);
const route = useRoute();
const router = useRouter();

View File

@ -0,0 +1,161 @@
<template>
<div>
<v-form ref="domUrlForm" @submit.prevent="createRecipe">
<div>
<v-card-title class="headline"> {{ $t('recipe.create-recipe-from-an-image') }} </v-card-title>
<v-card-text>
<p>{{ $t('recipe.create-recipe-from-an-image-description') }}</p>
<v-container class="pa-0">
<v-row>
<v-col cols="auto" align-self="center">
<AppButtonUpload
v-if="!uploadedImage"
class="ml-auto"
url="none"
file-name="image"
accept="image/*"
:text="$i18n.tc('recipe.upload-image')"
:text-btn="false"
:post="false"
@uploaded="uploadImage"
/>
<v-btn
v-if="!!uploadedImage"
color="error"
@click="clearImage"
>
<v-icon left>{{ $globals.icons.close }}</v-icon>
{{ $i18n.tc('recipe.remove-image') }}
</v-btn>
</v-col>
<v-spacer />
</v-row>
<div v-if="uploadedImage && uploadedImagePreviewUrl" class="mt-3">
<v-row>
<v-col cols="12" class="pb-0">
<v-card-text class="pa-0">
<p class="mb-0">
{{ $t('recipe.crop-and-rotate-the-image') }}
</p>
</v-card-text>
</v-col>
</v-row>
<v-row style="max-width: 600px;">
<v-spacer />
<v-col cols="12">
<ImageCropper
:img="uploadedImagePreviewUrl"
cropper-height="50vh"
cropper-width="100%"
@save="updateUploadedImage"
/>
</v-col>
<v-spacer />
</v-row>
</div>
</v-container>
</v-card-text>
<v-card-actions v-if="uploadedImage">
<div>
<p style="width: 250px">
<BaseButton rounded block type="submit" :loading="loading" />
</p>
<p>
<v-checkbox
v-model="shouldTranslate"
hide-details
:label="$t('recipe.should-translate-description')"
:disabled="loading"
/>
</p>
<p v-if="loading" class="mb-0">
{{ $t('recipe.please-wait-image-procesing') }}
</p>
</div>
</v-card-actions>
</div>
</v-form>
</div>
</template>
<script lang="ts">
import {
computed,
defineComponent,
reactive,
ref,
toRefs,
useContext,
useRoute,
useRouter,
} from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { VForm } from "~/types/vuetify";
export default defineComponent({
setup() {
const state = reactive({
loading: false,
});
const { i18n } = useContext();
const api = useUserApi();
const route = useRoute();
const router = useRouter();
const groupSlug = computed(() => route.value.params.groupSlug || "");
const domUrlForm = ref<VForm | null>(null);
const uploadedImage = ref<Blob | File>();
const uploadedImageName = ref<string>("");
const uploadedImagePreviewUrl = ref<string>();
const shouldTranslate = ref(true);
function uploadImage(fileObject: File) {
uploadedImage.value = fileObject;
uploadedImageName.value = fileObject.name;
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
function updateUploadedImage(fileObject: Blob) {
uploadedImage.value = fileObject;
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
function clearImage() {
uploadedImage.value = undefined;
uploadedImageName.value = "";
uploadedImagePreviewUrl.value = undefined;
}
async function createRecipe() {
if (!uploadedImage.value) {
return;
}
state.loading = true;
const translateLanguage = shouldTranslate.value ? i18n.locale : undefined;
const { data, error } = await api.recipes.createOneFromImage(uploadedImage.value, uploadedImageName.value, translateLanguage);
if (error || !data) {
alert.error(i18n.tc("events.something-went-wrong"));
state.loading = false;
} else {
router.push(`/g/${groupSlug.value}/r/${data}`);
};
}
return {
...toRefs(state),
domUrlForm,
uploadedImage,
uploadedImagePreviewUrl,
shouldTranslate,
uploadImage,
updateUploadedImage,
clearImage,
createRecipe,
};
},
});
</script>

View File

@ -304,6 +304,8 @@ class AppSettings(AppLoggingSettings):
"""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_ENABLE_IMAGE_SERVICES: bool = True
"""Whether to enable image-related features in OpenAI"""
OPENAI_WORKERS: int = 2
"""
Number of OpenAI workers per request. Higher values may increase
@ -314,8 +316,7 @@ class AppSettings(AppLoggingSettings):
Sending database data may increase accuracy in certain requests,
but will incur additional API costs
"""
OPENAI_REQUEST_TIMEOUT: int = 10
OPENAI_REQUEST_TIMEOUT: int = 60
"""
The number of seconds to wait for an OpenAI request to complete before cancelling the request
"""

View File

@ -34,6 +34,7 @@ class AdminAboutController(BaseAdminController):
oidc_redirect=settings.OIDC_AUTO_REDIRECT,
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
enable_openai=settings.OPENAI_ENABLED,
enable_openai_image_services=settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_IMAGE_SERVICES,
)
@router.get("/statistics", response_model=AppStatistics)

View File

@ -33,6 +33,7 @@ def get_app_info(session: Session = Depends(generate_session)):
oidc_redirect=settings.OIDC_AUTO_REDIRECT,
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
enable_openai=settings.OPENAI_ENABLED,
enable_openai_image_services=settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_IMAGE_SERVICES,
)

View File

@ -254,6 +254,9 @@ class RecipeController(BaseRecipeController):
return "recipe_scrapers was unable to scrape this URL"
# ==================================================================================================================
# Other Create Operations
@router.post("/create-from-zip", status_code=201)
def create_recipe_from_zip(self, archive: UploadFile = File(...)):
"""Create recipe from archive"""
@ -266,6 +269,31 @@ class RecipeController(BaseRecipeController):
return recipe.slug
@router.post("/create-from-image", status_code=201)
async def create_recipe_from_image(
self,
images: list[UploadFile] = File(...),
translate_language: str | None = Query(None, alias="translateLanguage"),
):
"""
Create a recipe from an image using OpenAI.
Optionally specify a language for it to translate the recipe to.
"""
if not (self.settings.OPENAI_ENABLED and self.settings.OPENAI_ENABLE_IMAGE_SERVICES):
raise HTTPException(
status_code=400,
detail=ErrorResponse.respond("OpenAI image services are not enabled"),
)
recipe = await self.service.create_from_images(images, translate_language)
self.publish_event(
event_type=EventTypes.recipe_created,
document_data=EventRecipeData(operation=EventOperation.create, recipe_slug=recipe.slug),
)
return recipe.slug
# ==================================================================================================================
# CRUD Operations

View File

@ -19,6 +19,7 @@ class AppInfo(MealieModel):
oidc_redirect: bool
oidc_provider_name: str
enable_openai: bool
enable_openai_image_services: bool
class AppTheme(MealieModel):

View File

@ -1,5 +1,9 @@
from pydantic import BaseModel
from mealie.core.root_logger import get_logger
logger = get_logger()
class OpenAIBase(BaseModel):
"""
@ -8,3 +12,16 @@ class OpenAIBase(BaseModel):
"""
__doc__ = "" # we don't want to include the docstring in the JSON schema
@classmethod
def parse_openai_response(cls, response: str | None):
"""
This method should be implemented in the child class. It should
parse the JSON response from OpenAI and return a dictionary.
"""
try:
return cls.model_validate_json(response or "")
except Exception:
logger.debug(f"Failed to parse OpenAI response as {cls}. Response: {response}")
raise

View File

@ -0,0 +1,205 @@
from textwrap import dedent
from pydantic import Field
from ._base import OpenAIBase
class OpenAIRecipeIngredient(OpenAIBase):
title: str | None = Field(
None,
description=dedent(
"""
The title of the section of the recipe that the ingredient is found in. Recipes may not specify
ingredient sections, in which case this should be left blank.
Only the first item in the section should have this set,
whereas subsuquent items should have their titles left blank (unless they start a new section).
"""
),
)
text: str = Field(
...,
description=dedent(
"""
The text of the ingredient. This should represent the entire ingredient, such as "1 cup of flour" or
"2 cups of onions, chopped". If the ingredient is completely blank, skip it and do not add the ingredient,
since this field is required.
If the ingredient has no text, but has a title, include the title on the
next ingredient instead.
"""
),
)
class OpenAIRecipeInstruction(OpenAIBase):
title: str | None = Field(
None,
description=dedent(
"""
The title of the section of the recipe that the instruction is found in. Recipes may not specify
instruction sections, in which case this should be left blank.
Only the first instruction in the section should have this set,
whereas subsuquent instructions should have their titles left blank (unless they start a new section).
"""
),
)
text: str = Field(
...,
description=dedent(
"""
The text of the instruction. This represents one step in the recipe, such as "Preheat the oven to 350",
or "Sauté the onions for 20 minutes". Sometimes steps can be longer, such as "Bring a large pot of lightly
salted water to a boil. Add ditalini pasta and cook for 8 minutes or until al dente; drain.".
Sometimes, but not always, recipes will include their number in front of the text, such as
"1.", "2.", or "Step 1", "Step 2", or "First", "Second". In the case where they are directly numbered
("1.", "2.", "Step one", "Step 1", "Step two", "Step 2", etc.), you should not include the number in
the text. However, if they use words ("First", "Second", etc.), then those should be included.
If the instruction is completely blank, skip it and do not add the instruction, since this field is
required. If the ingredient has no text, but has a title, include the title on the next
instruction instead.
"""
),
)
class OpenAIRecipeNotes(OpenAIBase):
title: str | None = Field(
None,
description=dedent(
"""
The title of the note. Notes may not specify a title, and just have a body of text. In this case,
title should be left blank, and all content should go in the note text. If the note title is just
"note" or "info", you should ignore it and leave the title blank.
"""
),
)
text: str = Field(
...,
description=dedent(
"""
The text of the note. This should represent the entire note, such as "This recipe is great for
a summer picnic" or "This recipe is a family favorite". They may also include additional prep
instructions such as "to make this recipe gluten free, use gluten free flour", or "you may prepare
the dough the night before and refrigerate it until ready to bake".
If the note is completely blank, skip it and do not add the note, since this field is required.
"""
),
)
class OpenAIRecipe(OpenAIBase):
name: str = Field(
...,
description=dedent(
"""
The name or title of the recipe. If you're unable to determine the name of the recipe, you should
make your best guess based upon the ingredients and instructions provided.
"""
),
)
description: str | None = Field(
...,
description=dedent(
"""
A long description of the recipe. This should be a string that describes the recipe in a few words
or sentences. If the recipe doesn't have a description, you should return None.
"""
),
)
recipe_yield: str | None = Field(
None,
description=dedent(
"""
The yield of the recipe. For instance, if the recipe makes 12 cookies, the yield is "12 cookies".
If the recipe makes 2 servings, the yield is "2 servings". Typically yield consists of a number followed
by the word "serving" or "servings", but it can be any string that describes the yield. If the yield
isn't specified, you should return None.
"""
),
)
total_time: str | None = Field(
None,
description=dedent(
"""
The total time it takes to make the recipe. This should be a string that describes a duration of time,
such as "1 hour and 30 minutes", "90 minutes", or "1.5 hours". If the recipe has multiple times, choose
the longest time. If the recipe doesn't specify a total time or duration, or it specifies a prep time or
perform time but not a total time, you should return None. Do not duplicate times between total time, prep
time and perform time.
"""
),
)
prep_time: str | None = Field(
None,
description=dedent(
"""
The time it takes to prepare the recipe. This should be a string that describes a duration of time,
such as "30 minutes", "1 hour", or "1.5 hours". If the recipe has a total time, the prep time should be
less than the total time. If the recipe doesn't specify a prep time, you should return None. If the recipe
supplies only one time, it should be the total time. Do not duplicate times between total time, prep
time and coperformok time.
"""
),
)
perform_time: str | None = Field(
None,
description=dedent(
"""
The time it takes to cook the recipe. This should be a string that describes a duration of time,
such as "30 minutes", "1 hour", or "1.5 hours". If the recipe has a total time, the perform time should be
less than the total time. If the recipe doesn't specify a perform time, you should return None. If the
recipe specifies a cook time, active time, or other time besides total or prep, you should use that
time as the perform time. If the recipe supplies only one time, it should be the total time, and not the
perform time. Do not duplicate times between total time, prep time and perform time.
"""
),
)
ingredients: list[OpenAIRecipeIngredient] = Field(
[],
description=dedent(
"""
A list of ingredients used in the recipe. Ingredients should be inserted in the order they appear in the
recipe. If the recipe has no ingredients, you should return an empty list.
Often times, but not always, ingredients are separated by line breaks. Use these as a guide to
separate ingredients.
"""
),
)
instructions: list[OpenAIRecipeInstruction] = Field(
[],
description=dedent(
"""
A list of ingredients used in the recipe. Ingredients should be inserted in the order they appear in the
recipe. If the recipe has no ingredients, you should return an empty list.
Often times, but not always, instructions are separated by line breaks and/or separated by paragraphs.
Use these as a guide to separate instructions. They also may be separated by numbers or words, such as
"1.", "2.", "Step 1", "Step 2", "First", "Second", etc.
"""
),
)
notes: list[OpenAIRecipeNotes] = Field(
[],
description=dedent(
"""
A list of notes found in the recipe. Notes should be inserted in the order they appear in the recipe.
They may appear anywhere on the recipe, though they are typically found under the instructions.
"""
),
)

View File

@ -1,6 +1,8 @@
from .openai import OpenAIDataInjection, OpenAIService
from .openai import OpenAIDataInjection, OpenAIImageExternal, OpenAILocalImage, OpenAIService
__all__ = [
"OpenAIDataInjection",
"OpenAIImageExternal",
"OpenAILocalImage",
"OpenAIService",
]

View File

@ -1,6 +1,8 @@
import base64
import inspect
import json
import os
from abc import ABC, abstractmethod
from pathlib import Path
from textwrap import dedent
@ -9,6 +11,7 @@ from openai.resources.chat.completions import ChatCompletion
from pydantic import BaseModel, field_validator
from mealie.core.config import get_app_settings
from mealie.pkgs import img
from .._base_service import BaseService
@ -39,6 +42,37 @@ class OpenAIDataInjection(BaseModel):
return value
class OpenAIImageBase(BaseModel, ABC):
@abstractmethod
def get_image_url(self) -> str: ...
def build_message(self) -> dict:
return {
"type": "image_url",
"image_url": {"url": self.get_image_url()},
}
class OpenAIImageExternal(OpenAIImageBase):
url: str
def get_image_url(self) -> str:
return self.url
class OpenAILocalImage(OpenAIImageBase):
filename: str
path: Path
def get_image_url(self) -> str:
image = img.PillowMinifier.to_webp(
self.path, dest=self.path.parent.joinpath(f"{self.filename}-min-original.webp")
)
with open(image, "rb") as f:
b64content = base64.b64encode(f.read()).decode("utf-8")
return f"data:image/webp;base64,{b64content}"
class OpenAIService(BaseService):
PROMPTS_DIR = Path(os.path.dirname(os.path.abspath(__file__))) / "prompts"
@ -50,6 +84,7 @@ class OpenAIService(BaseService):
self.model = settings.OPENAI_MODEL
self.workers = settings.OPENAI_WORKERS
self.send_db_data = settings.OPENAI_SEND_DATABASE_DATA
self.enable_image_services = settings.OPENAI_ENABLE_IMAGE_SERVICES
self.get_client = lambda: AsyncOpenAI(
base_url=settings.OPENAI_BASE_URL,
@ -99,7 +134,7 @@ class OpenAIService(BaseService):
return "\n".join(content_parts)
async def _get_raw_response(
self, prompt: str, message: str, temperature=0.2, force_json_response=True
self, prompt: str, content: list[dict], temperature=0.2, force_json_response=True
) -> ChatCompletion:
client = self.get_client()
return await client.chat.completions.create(
@ -110,7 +145,7 @@ class OpenAIService(BaseService):
},
{
"role": "user",
"content": message,
"content": content,
},
],
model=self.model,
@ -118,10 +153,26 @@ class OpenAIService(BaseService):
response_format={"type": "json_object"} if force_json_response else NOT_GIVEN,
)
async def get_response(self, prompt: str, message: str, temperature=0.2, force_json_response=True) -> str | None:
async def get_response(
self,
prompt: str,
message: str,
*,
images: list[OpenAIImageBase] | None = None,
temperature=0.2,
force_json_response=True,
) -> str | None:
"""Send data to OpenAI and return the response message content"""
if images and not self.enable_image_services:
self.logger.warning("OpenAI image services are disabled, ignoring images")
images = None
try:
response = await self._get_raw_response(prompt, message, temperature, force_json_response)
user_messages = [{"type": "text", "text": message}]
for image in images or []:
user_messages.append(image.build_message())
response = await self._get_raw_response(prompt, user_messages, temperature, force_json_response)
if not response.choices:
return None
return response.choices[0].message.content

View File

@ -0,0 +1,11 @@
You are a bot that reads an image, or a set of images, and parses it into recipe JSON. You will receive an image from the user and you need to extract the recipe data and return its JSON in valid schema. The recipe schema will be included at the bottom of this message.
It is imperative that you do not create any data or otherwise make up any information. Failure to adhere to this rule is illegal and will result in harsh punishment. If you are unable to extract data due to insufficient input, you may reply with a completely empty JSON object (represented by two brackets: {}).
Do not under any circumstances insert data not found directly in the image. Ingredients, instructions, and notes should come directly from the image and not be generated or otherwise made up. It is illegal for you to create information not found directly in the image.
Your response must be in valid JSON in the provided Recipe definition below. You must respond in this JSON schema; failure to do so is illegal. It is imperative that you follow the schema precisely to avoid punishment. You must follow the JSON schema.
The user message that you receive will be one or more images. Assume all images provided belong to a single recipe, not multiple recipes. The recipe may consist of printed text or handwritten text. It may be rotated or not properly cropped. It is your job to figure out which part of the image is the important content and extract it.
The text you receive in the provided image or images may not be in English. The user may provide a language for you to translate the recipe into. If the user doesn't ask for a translation, you should preserve the text as-is without translating or otherwise modifying it. Otherwise, you should translate all text (recipe name, ingredients, instructions, etc.) to the requested language.

View File

@ -82,7 +82,7 @@ class OpenAIParser(ABCIngredientParser):
# re-combine chunks into one response
responses_json = await asyncio.gather(*tasks)
responses = [
OpenAIIngredients.model_validate_json(response_json) for response_json in responses_json if responses_json
OpenAIIngredients.parse_openai_response(response_json) for response_json in responses_json if responses_json
]
if not responses:
raise Exception("No response from OpenAI")

View File

@ -1,4 +1,5 @@
import json
import os
import shutil
from datetime import datetime, timezone
from pathlib import Path
@ -11,24 +12,29 @@ from fastapi import UploadFile
from slugify import slugify
from mealie.core import exceptions
from mealie.core.config import get_app_settings
from mealie.core.dependencies.dependencies import get_temporary_path
from mealie.lang.providers import Translator
from mealie.pkgs import cache
from mealie.repos.repository_factory import AllRepositories
from mealie.repos.repository_generic import RepositoryGeneric
from mealie.schema.openai.recipe import OpenAIRecipe
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_notes import RecipeNote
from mealie.schema.recipe.recipe_settings import RecipeSettings
from mealie.schema.recipe.recipe_step import RecipeStep
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
from mealie.schema.recipe.request_helpers import RecipeDuplicate
from mealie.schema.user.user import GroupInDB, PrivateUser, UserRatingCreate
from mealie.services._base_service import BaseService
from mealie.services.openai import OpenAIDataInjection, OpenAILocalImage, OpenAIService
from mealie.services.recipe.recipe_data_service import RecipeDataService
from .template_service import TemplateService
class RecipeService(BaseService):
class RecipeServiceBase(BaseService):
def __init__(self, repos: AllRepositories, user: PrivateUser, group: GroupInDB, translator: Translator):
self.repos = repos
self.user = user
@ -39,6 +45,8 @@ class RecipeService(BaseService):
super().__init__()
class RecipeService(RecipeServiceBase):
def _get_recipe(self, data: str | UUID, key: str | None = None) -> Recipe:
recipe = self.repos.recipes.by_group(self.group.id).get_one(data, key)
if recipe is None:
@ -250,6 +258,26 @@ class RecipeService(BaseService):
return recipe
async def create_from_images(self, images: list[UploadFile], translate_language: str | None = None) -> Recipe:
openai_recipe_service = OpenAIRecipeService(self.repos, self.user, self.group, self.translator)
with get_temporary_path() as temp_path:
local_images: list[Path] = []
for image in images:
with temp_path.joinpath(image.filename).open("wb") as buffer:
shutil.copyfileobj(image.file, buffer)
local_images.append(temp_path.joinpath(image.filename))
recipe_data = await openai_recipe_service.build_recipe_from_images(
local_images, translate_language=translate_language
)
recipe = self.create_one(recipe_data)
data_service = RecipeDataService(recipe.id)
with open(local_images[0], "rb") as f:
data_service.write_image(f.read(), "webp")
return recipe
def duplicate_one(self, old_slug: str, dup_data: RecipeDuplicate) -> Recipe:
"""Duplicates a recipe and returns the new recipe."""
@ -379,3 +407,67 @@ class RecipeService(BaseService):
def render_template(self, recipe: Recipe, temp_dir: Path, template: str) -> Path:
t_service = TemplateService(temp_dir)
return t_service.render(recipe, template)
class OpenAIRecipeService(RecipeServiceBase):
def _convert_recipe(self, openai_recipe: OpenAIRecipe) -> Recipe:
return Recipe(
user_id=self.user.id,
group_id=self.user.group_id,
name=openai_recipe.name,
slug=slugify(openai_recipe.name),
description=openai_recipe.description,
recipe_yield=openai_recipe.recipe_yield,
total_time=openai_recipe.total_time,
prep_time=openai_recipe.prep_time,
perform_time=openai_recipe.perform_time,
recipe_ingredient=[
RecipeIngredient(title=ingredient.title, note=ingredient.text)
for ingredient in openai_recipe.ingredients
if ingredient.text
],
recipe_instructions=[
RecipeStep(title=instruction.title, text=instruction.text)
for instruction in openai_recipe.instructions
if instruction.text
],
notes=[RecipeNote(title=note.title or "", text=note.text) for note in openai_recipe.notes if note.text],
)
async def build_recipe_from_images(self, images: list[Path], translate_language: str | None) -> Recipe:
settings = get_app_settings()
if not (settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_IMAGE_SERVICES):
raise ValueError("OpenAI image services are not available")
openai_service = OpenAIService()
prompt = openai_service.get_prompt(
"recipes.parse-recipe-image",
data_injections=[
OpenAIDataInjection(
description=(
"This is the JSON response schema. You must respond in valid JSON that follows this schema. "
"Your payload should be as compact as possible, eliminating unncessesary whitespace. "
"Any fields with default values which you do not populate should not be in the payload."
),
value=OpenAIRecipe,
)
],
)
openai_images = [OpenAILocalImage(filename=os.path.basename(image), path=image) for image in images]
message = (
f"Please extract the recipe from the {'images' if len(openai_images) > 1 else 'image'} provided."
"There should be exactly one recipe."
)
if translate_language:
message += f" Please translate the recipe to {translate_language}."
response = await openai_service.get_response(prompt, message, images=openai_images, force_json_response=True)
try:
openai_recipe = OpenAIRecipe.parse_openai_response(response)
recipe = self._convert_recipe(openai_recipe)
except Exception as e:
raise ValueError("Unable to parse recipe from image") from e
return recipe

View File

@ -0,0 +1,58 @@
import json
import pytest
from fastapi.testclient import TestClient
from mealie.schema.openai.recipe import (
OpenAIRecipe,
OpenAIRecipeIngredient,
OpenAIRecipeInstruction,
OpenAIRecipeNotes,
)
from mealie.services.openai import OpenAIService
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
def test_openai_create_recipe_from_image(
api_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
unique_user: TestUser,
test_image_jpg: str,
):
async def mock_get_response(self, prompt: str, message: str, *args, **kwargs) -> str | None:
data = OpenAIRecipe(
name=random_string(),
description=random_string(),
recipe_yield=random_string(),
total_time=random_string(),
prep_time=random_string(),
perform_time=random_string(),
ingredients=[OpenAIRecipeIngredient(text=random_string()) for _ in range(random_int(5, 10))],
instructions=[OpenAIRecipeInstruction(text=random_string()) for _ in range(1, random_int(5, 10))],
notes=[OpenAIRecipeNotes(text=random_string()) for _ in range(random_int(2, 5))],
)
return data.model_dump_json()
monkeypatch.setattr(OpenAIService, "get_response", mock_get_response)
with open(test_image_jpg, "rb") as f:
r = api_client.post(
api_routes.recipes_create_from_image,
files={"images": ("test_image_jpg.jpg", f, "image/jpeg")},
data={"extension": "jpg"},
headers=unique_user.token,
)
assert r.status_code == 201
# since OpenAI is mocked, we don't need to validate the data, we just need to make sure a recipe is created,
# and that it has an image
slug: str = json.loads(r.text)
r = api_client.get(api_routes.recipes_slug(slug), headers=unique_user.token)
assert r.status_code == 200
recipe_id = r.json()["id"]
r = api_client.get(
api_routes.media_recipes_recipe_id_images_file_name(recipe_id, "original.webp"), headers=unique_user.token
)
assert r.status_code == 200

View File

@ -17,20 +17,14 @@ admin_email = "/api/admin/email"
"""`/api/admin/email`"""
admin_groups = "/api/admin/groups"
"""`/api/admin/groups`"""
admin_logs = "/api/admin/logs"
"""`/api/admin/logs`"""
admin_maintenance = "/api/admin/maintenance"
"""`/api/admin/maintenance`"""
admin_maintenance_clean_images = "/api/admin/maintenance/clean/images"
"""`/api/admin/maintenance/clean/images`"""
admin_maintenance_clean_logs = "/api/admin/maintenance/clean/logs"
"""`/api/admin/maintenance/clean/logs`"""
admin_maintenance_clean_recipe_folders = "/api/admin/maintenance/clean/recipe-folders"
"""`/api/admin/maintenance/clean/recipe-folders`"""
admin_maintenance_clean_temp = "/api/admin/maintenance/clean/temp"
"""`/api/admin/maintenance/clean/temp`"""
admin_maintenance_logs = "/api/admin/maintenance/logs"
"""`/api/admin/maintenance/logs`"""
admin_maintenance_storage = "/api/admin/maintenance/storage"
"""`/api/admin/maintenance/storage`"""
admin_users = "/api/admin/users"
@ -47,6 +41,8 @@ app_about_startup_info = "/api/app/about/startup-info"
"""`/api/app/about/startup-info`"""
app_about_theme = "/api/app/about/theme"
"""`/api/app/about/theme`"""
auth_logout = "/api/auth/logout"
"""`/api/auth/logout`"""
auth_refresh = "/api/auth/refresh"
"""`/api/auth/refresh`"""
auth_token = "/api/auth/token"
@ -143,6 +139,8 @@ recipes_bulk_actions_settings = "/api/recipes/bulk-actions/settings"
"""`/api/recipes/bulk-actions/settings`"""
recipes_bulk_actions_tag = "/api/recipes/bulk-actions/tag"
"""`/api/recipes/bulk-actions/tag`"""
recipes_create_from_image = "/api/recipes/create-from-image"
"""`/api/recipes/create-from-image`"""
recipes_create_from_zip = "/api/recipes/create-from-zip"
"""`/api/recipes/create-from-zip`"""
recipes_create_url = "/api/recipes/create-url"
@ -212,11 +210,6 @@ def admin_groups_item_id(item_id):
return f"{prefix}/admin/groups/{item_id}"
def admin_logs_num(num):
"""`/api/admin/logs/{num}`"""
return f"{prefix}/admin/logs/{num}"
def admin_users_item_id(item_id):
"""`/api/admin/users/{item_id}`"""
return f"{prefix}/admin/users/{item_id}"
@ -362,6 +355,11 @@ def groups_webhooks_item_id(item_id):
return f"{prefix}/groups/webhooks/{item_id}"
def groups_webhooks_item_id_test(item_id):
"""`/api/groups/webhooks/{item_id}/test`"""
return f"{prefix}/groups/webhooks/{item_id}/test"
def media_recipes_recipe_id_assets_file_name(recipe_id, file_name):
"""`/api/media/recipes/{recipe_id}/assets/{file_name}`"""
return f"{prefix}/media/recipes/{recipe_id}/assets/{file_name}"